Compare commits
No commits in common. "main" and "v1.6.4" have entirely different histories.
3
.gitignore
vendored
3
.gitignore
vendored
@ -261,6 +261,5 @@ paket-files/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
/.cursor
|
|
||||||
/.vscode
|
|
||||||
/staging
|
/staging
|
||||||
16
CHANGELOG.md
16
CHANGELOG.md
@ -1,23 +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).
|
||||||
|
|
||||||
## v1.6.5 - 2026-02-02
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Replaced explicit `ArgumentNullException` throws with `ArgumentNullException.ThrowIfNull` in `ExpressionExtensions`, `NetworkConnection`, `Base32Encoder`, `StringExtensions.CSVToDataTable`, `FileLoggerProvider`, and `JsonFileLoggerProvider`.
|
|
||||||
- **Base32Encoder**: empty input now throws `ArgumentException` instead of `ArgumentNullException` for clearer semantics.
|
|
||||||
- **StringExtensions.CSVToDataTable**: null file path throws via `ThrowIfNull`; empty/whitespace path throws `ArgumentException`.
|
|
||||||
- **ObjectExtensions**: `DeepClone` returns `default` for null input; `DeepEqual` explicitly treats (null, null) as true and (null, non-null) as false. Replaced obsolete `FormatterServices.GetUninitializedObject` with `RuntimeHelpers.GetUninitializedObject`. Fixed nullability in `ReferenceEqualityComparer` to match `IEqualityComparer<object>`.
|
|
||||||
- **TotpGenerator**: recovery code generation uses range syntax (`code[..4]`, `code[4..8]`) instead of `Substring`.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **ExceptionExtensions.ExtractMessages**: null check added to avoid `NullReferenceException` when passed null.
|
|
||||||
- **BaseFileLogger.RemoveExpiredLogFiles**: guard added before `Substring(4)` so malformed log file names do not throw.
|
|
||||||
|
|
||||||
## v1.6.4 - 2026-02-21
|
## v1.6.4 - 2026-02-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@ -47,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Security
|
### Security
|
||||||
- Kept release-time git checks and branch/tag validation in shared release flow to reduce accidental publish risk.
|
- Kept release-time git checks and branch/tag validation in shared release flow to reduce accidental publish risk.
|
||||||
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Template for new releases:
|
Template for new releases:
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,7 @@ Thank you for your interest in contributing to MaksIT.Core! This document provid
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src
|
cd src
|
||||||
dotnet build MaksIT.Core.slnx
|
dotnet build MaksIT.Core.sln
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
@ -46,14 +46,7 @@ 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):` | Code refactoring without functional changes |
|
||||||
| `(perf):` | Performance improvement without changing behavior |
|
| `(chore):` | Maintenance tasks (dependencies, CI, documentation) |
|
||||||
| `(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
|
### Examples
|
||||||
|
|
||||||
@ -61,13 +54,6 @@ This project uses the following commit message format:
|
|||||||
(feature): add support for custom JWT claims
|
(feature): add support for custom JWT claims
|
||||||
(bugfix): fix multithreading issue in file logger
|
(bugfix): fix multithreading issue in file logger
|
||||||
(refactor): simplify expression extension methods
|
(refactor): simplify expression extension methods
|
||||||
(perf): reduce allocations in Base32 encoder
|
|
||||||
(test): add coverage for IQueryable predicate composition
|
|
||||||
(docs): clarify release workflow prerequisites
|
|
||||||
(build): update package metadata in MaksIT.Core.csproj
|
|
||||||
(ci): update GitHub Actions workflow for .NET 10
|
|
||||||
(style): normalize using directives in extension tests
|
|
||||||
(revert): revert breaking change in network connection handling
|
|
||||||
(chore): update copyright year to 2026
|
(chore): update copyright year to 2026
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -260,4 +246,4 @@ If the release partially failed (e.g., NuGet succeeded but GitHub failed):
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
By contributing, you agree that your contributions are licensed under the terms in `LICENSE.md`.
|
By contributing, you agree that your contributions will be licensed under the MIT License.
|
||||||
|
|||||||
@ -1614,10 +1614,7 @@ string completedName = completed.GetDisplayName(); // "Completed"
|
|||||||
|
|
||||||
## Contact
|
## Contact
|
||||||
|
|
||||||
If you have any questions or need further assistance, feel free to reach out:
|
For any inquiries or contributions, feel free to reach out:
|
||||||
|
|
||||||
- **Email**: [maksym.sadovnychyy@gmail.com](mailto:maksym.sadovnychyy@gmail.com)
|
- **Email**: maksym.sadovnychyy@gmail.com
|
||||||
|
- **Author**: Maksym Sadovnychyy (MAKS-IT)
|
||||||
## License
|
|
||||||
|
|
||||||
See `LICENSE.md`.
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Branch Coverage: 50.3%">
|
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Branch Coverage: 49.6%">
|
||||||
<title>Branch Coverage: 50.3%</title>
|
<title>Branch Coverage: 49.6%</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">50.3%</text>
|
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">49.6%</text>
|
||||||
<text x="128.75" y="14" fill="#fff">50.3%</text>
|
<text x="128.75" y="14" fill="#fff">49.6%</text>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@ -1,21 +1,21 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 60.1%">
|
<svg xmlns="http://www.w3.org/2000/svg" width="134.5" height="20" role="img" aria-label="Line Coverage: 60%">
|
||||||
<title>Line Coverage: 60.1%</title>
|
<title>Line Coverage: 60%</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"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<clipPath id="r">
|
<clipPath id="r">
|
||||||
<rect width="137" height="20" rx="3" fill="#fff"/>
|
<rect width="134.5" height="20" rx="3" fill="#fff"/>
|
||||||
</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="#97ca00"/>
|
<rect x="94.5" width="40" height="20" fill="#97ca00"/>
|
||||||
<rect width="137" height="20" fill="url(#s)"/>
|
<rect width="134.5" 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">60.1%</text>
|
<text aria-hidden="true" x="114.5" y="15" fill="#010101" fill-opacity=".3">60%</text>
|
||||||
<text x="115.75" y="14" fill="#fff">60.1%</text>
|
<text x="114.5" y="14" fill="#fff">60%</text>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@ -1,4 +1,4 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 69.2%">
|
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 69.2%">
|
||||||
<title>Method Coverage: 69.2%</title>
|
<title>Method Coverage: 69.2%</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"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@ -77,10 +77,4 @@ public class ExceptionExtensionsTests {
|
|||||||
Assert.Single(messages);
|
Assert.Single(messages);
|
||||||
Assert.Equal("", messages[0]);
|
Assert.Equal("", messages[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ExtractMessages_WhenNull_ThrowsArgumentNullException() {
|
|
||||||
Exception? exception = null;
|
|
||||||
Assert.Throws<ArgumentNullException>(() => exception!.ExtractMessages());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
using System.Linq;
|
using System.Linq.Expressions;
|
||||||
using System.Linq.Expressions;
|
|
||||||
|
|
||||||
using MaksIT.Core.Extensions;
|
using MaksIT.Core.Extensions;
|
||||||
|
|
||||||
@ -24,28 +23,6 @@ public class ExpressionExtensionsTests {
|
|||||||
Assert.False(compiledPredicate(new TestEntity { Age = 20, Name = "Bob" }));
|
Assert.False(compiledPredicate(new TestEntity { Age = 20, Name = "Bob" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ensures AndAlso produces an expression that works with IQueryable.Where (as used by EF Core).
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void AndAlso_ShouldWorkWithIQueryableWhere() {
|
|
||||||
var source = new List<TestEntity> {
|
|
||||||
new() { Age = 20, Name = "Alice" },
|
|
||||||
new() { Age = 17, Name = "Alice" },
|
|
||||||
new() { Age = 20, Name = "Bob" },
|
|
||||||
new() { Age = 25, Name = "Amy" }
|
|
||||||
};
|
|
||||||
Expression<Func<TestEntity, bool>> first = x => x.Age > 18;
|
|
||||||
Expression<Func<TestEntity, bool>> second = x => (x.Name ?? "").StartsWith("A");
|
|
||||||
var combined = first.AndAlso(second);
|
|
||||||
|
|
||||||
var result = source.AsQueryable().Where(combined).ToList();
|
|
||||||
|
|
||||||
Assert.Equal(2, result.Count);
|
|
||||||
Assert.Contains(result, e => e.Name == "Alice" && e.Age == 20);
|
|
||||||
Assert.Contains(result, e => e.Name == "Amy" && e.Age == 25);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OrElse_ShouldCombineTwoPredicatesWithOrCondition() {
|
public void OrElse_ShouldCombineTwoPredicatesWithOrCondition() {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
@ -235,13 +235,6 @@ namespace MaksIT.Core.Tests.Extensions {
|
|||||||
Assert.Same(s, s2);
|
Assert.Same(s, s2);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DeepClone_WhenNull_ReturnsDefault() {
|
|
||||||
Person? source = null;
|
|
||||||
var result = source.DeepClone();
|
|
||||||
Assert.Null(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DeepEqual_ShouldReturnTrue_ForEqualGraphs() {
|
public void DeepEqual_ShouldReturnTrue_ForEqualGraphs() {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
31
src/MaksIT.Core.sln
Normal file
31
src/MaksIT.Core.sln
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.5.002.0
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.Core", "MaksIT.Core\MaksIT.Core.csproj", "{4AE39520-D4F7-4C5F-ACE9-9E79AEAF3228}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaksIT.Core.Tests", "MaksIT.Core.Tests\MaksIT.Core.Tests.csproj", "{B67A43DA-AFFC-4510-8D51-08F1FF84CC5B}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{4AE39520-D4F7-4C5F-ACE9-9E79AEAF3228}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{4AE39520-D4F7-4C5F-ACE9-9E79AEAF3228}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{4AE39520-D4F7-4C5F-ACE9-9E79AEAF3228}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{4AE39520-D4F7-4C5F-ACE9-9E79AEAF3228}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{B67A43DA-AFFC-4510-8D51-08F1FF84CC5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{B67A43DA-AFFC-4510-8D51-08F1FF84CC5B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{B67A43DA-AFFC-4510-8D51-08F1FF84CC5B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{B67A43DA-AFFC-4510-8D51-08F1FF84CC5B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {9BCC72D1-8BE8-4924-AF73-C8E86E16EC59}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@ -1,4 +0,0 @@
|
|||||||
<Solution>
|
|
||||||
<Project Path="MaksIT.Core.Tests/MaksIT.Core.Tests.csproj" />
|
|
||||||
<Project Path="MaksIT.Core/MaksIT.Core.csproj" />
|
|
||||||
</Solution>
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
namespace MaksIT.Core.Extensions;
|
namespace MaksIT.Core.Extensions;
|
||||||
|
|
||||||
public static class ExceptionExtensions {
|
public static class ExceptionExtensions {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -7,7 +7,6 @@ public static class ExceptionExtensions {
|
|||||||
/// <param name="exception">The exception to extract messages from.</param>
|
/// <param name="exception">The exception to extract messages from.</param>
|
||||||
/// <returns>A list of exception messages.</returns>
|
/// <returns>A list of exception messages.</returns>
|
||||||
public static List<string> ExtractMessages(this Exception exception) {
|
public static List<string> ExtractMessages(this Exception exception) {
|
||||||
ArgumentNullException.ThrowIfNull(exception);
|
|
||||||
var messages = new List<string>();
|
var messages = new List<string>();
|
||||||
var current = exception;
|
var current = exception;
|
||||||
|
|
||||||
|
|||||||
@ -1,19 +1,10 @@
|
|||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
|
|
||||||
|
|
||||||
namespace MaksIT.Core.Extensions;
|
namespace MaksIT.Core.Extensions;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Extension methods for combining and negating expression predicates.
|
|
||||||
/// AndAlso and OrElse use parameter replacement (no Expression.Invoke), so the result
|
|
||||||
/// is safe for use with IQueryable and EF Core (translatable to SQL).
|
|
||||||
/// </summary>
|
|
||||||
public static class ExpressionExtensions {
|
public static class ExpressionExtensions {
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Combines two predicates with AND. Uses a single parameter and Expression.AndAlso;
|
|
||||||
/// safe for IQueryable/EF Core (no Invoke).
|
|
||||||
/// </summary>
|
|
||||||
public static Expression<Func<T, bool>> AndAlso<T>(this Expression<Func<T, bool>> first, Expression<Func<T, bool>> second) {
|
public static Expression<Func<T, bool>> AndAlso<T>(this Expression<Func<T, bool>> first, Expression<Func<T, bool>> second) {
|
||||||
ArgumentNullException.ThrowIfNull(first);
|
ArgumentNullException.ThrowIfNull(first);
|
||||||
ArgumentNullException.ThrowIfNull(second);
|
ArgumentNullException.ThrowIfNull(second);
|
||||||
@ -26,10 +17,6 @@ public static class ExpressionExtensions {
|
|||||||
return Expression.Lambda<Func<T, bool>>(combinedBody, parameter);
|
return Expression.Lambda<Func<T, bool>>(combinedBody, parameter);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Combines two predicates with OR. Uses a single parameter and Expression.OrElse;
|
|
||||||
/// safe for IQueryable/EF Core (no Invoke).
|
|
||||||
/// </summary>
|
|
||||||
public static Expression<Func<T, bool>> OrElse<T>(this Expression<Func<T, bool>> first, Expression<Func<T, bool>> second) {
|
public static Expression<Func<T, bool>> OrElse<T>(this Expression<Func<T, bool>> first, Expression<Func<T, bool>> second) {
|
||||||
ArgumentNullException.ThrowIfNull(first);
|
ArgumentNullException.ThrowIfNull(first);
|
||||||
ArgumentNullException.ThrowIfNull(second);
|
ArgumentNullException.ThrowIfNull(second);
|
||||||
@ -43,7 +30,7 @@ public static class ExpressionExtensions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static Expression<Func<T, bool>> Not<T>(this Expression<Func<T, bool>> expression) {
|
public static Expression<Func<T, bool>> Not<T>(this Expression<Func<T, bool>> expression) {
|
||||||
ArgumentNullException.ThrowIfNull(expression);
|
if (expression == null) throw new ArgumentNullException(nameof(expression));
|
||||||
|
|
||||||
var parameter = expression.Parameters[0];
|
var parameter = expression.Parameters[0];
|
||||||
var body = Expression.Not(expression.Body);
|
var body = Expression.Not(expression.Body);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.Serialization;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
@ -41,7 +42,6 @@ public static class ObjectExtensions {
|
|||||||
/// Creates a deep clone of the object, preserving reference identity and supporting cycles.
|
/// Creates a deep clone of the object, preserving reference identity and supporting cycles.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static T DeepClone<T>(this T source) {
|
public static T DeepClone<T>(this T source) {
|
||||||
if (source is null) return default!;
|
|
||||||
return (T)DeepCloneInternal(source, new Dictionary<object, object>(ReferenceEqualityComparer.Instance));
|
return (T)DeepCloneInternal(source, new Dictionary<object, object>(ReferenceEqualityComparer.Instance));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,9 +49,7 @@ public static class ObjectExtensions {
|
|||||||
/// Deeply compares two objects for structural equality (fields, including private ones).
|
/// Deeply compares two objects for structural equality (fields, including private ones).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool DeepEqual<T>(this T a, T b) {
|
public static bool DeepEqual<T>(this T a, T b) {
|
||||||
if (a is null && b is null) return true;
|
return DeepEqualInternal(a, b, new HashSet<(object, object)>(ReferencePairComparer.Instance));
|
||||||
if (a is null || b is null) return false;
|
|
||||||
return DeepEqualInternal(a!, b!, new HashSet<(object, object)>(ReferencePairComparer.Instance));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -86,7 +84,7 @@ public static class ObjectExtensions {
|
|||||||
return CloneStruct(source, type, visited);
|
return CloneStruct(source, type, visited);
|
||||||
|
|
||||||
// Reference type: allocate uninitialized object, then copy fields
|
// Reference type: allocate uninitialized object, then copy fields
|
||||||
var clone = RuntimeHelpers.GetUninitializedObject(type);
|
var clone = FormatterServices.GetUninitializedObject(type);
|
||||||
visited[source] = clone;
|
visited[source] = clone;
|
||||||
CopyAllFields(source, clone, type, visited);
|
CopyAllFields(source, clone, type, visited);
|
||||||
return clone;
|
return clone;
|
||||||
@ -251,8 +249,8 @@ public static class ObjectExtensions {
|
|||||||
|
|
||||||
private sealed class ReferenceEqualityComparer : IEqualityComparer<object> {
|
private sealed class ReferenceEqualityComparer : IEqualityComparer<object> {
|
||||||
public static readonly ReferenceEqualityComparer Instance = new ReferenceEqualityComparer();
|
public static readonly ReferenceEqualityComparer Instance = new ReferenceEqualityComparer();
|
||||||
public new bool Equals(object? x, object? y) => ReferenceEquals(x, y);
|
public new bool Equals(object x, object y) => ReferenceEquals(x, y);
|
||||||
public int GetHashCode(object? obj) => RuntimeHelpers.GetHashCode(obj!);
|
public int GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class ReferencePairComparer : IEqualityComparer<(object, object)> {
|
private sealed class ReferencePairComparer : IEqualityComparer<(object, object)> {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Data;
|
using System.Data;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
@ -246,8 +246,8 @@ namespace MaksIT.Core.Extensions {
|
|||||||
public static string ToKebabCase(this string input) => input.ToCase(StringCaseStyle.KebabCase);
|
public static string ToKebabCase(this string input) => input.ToCase(StringCaseStyle.KebabCase);
|
||||||
|
|
||||||
public static DataTable CSVToDataTable(this string filePath) {
|
public static DataTable CSVToDataTable(this string filePath) {
|
||||||
ArgumentNullException.ThrowIfNull(filePath);
|
if (string.IsNullOrEmpty(filePath))
|
||||||
if (string.IsNullOrWhiteSpace(filePath)) throw new ArgumentException("File path cannot be empty.", nameof(filePath));
|
throw new ArgumentNullException(nameof(filePath));
|
||||||
|
|
||||||
using var sr = new StreamReader(filePath);
|
using var sr = new StreamReader(filePath);
|
||||||
|
|
||||||
|
|||||||
@ -78,8 +78,7 @@ public abstract class BaseFileLogger : ILogger, IDisposable {
|
|||||||
|
|
||||||
foreach (var logFile in logFiles) {
|
foreach (var logFile in logFiles) {
|
||||||
var fileName = Path.GetFileNameWithoutExtension(logFile);
|
var fileName = Path.GetFileNameWithoutExtension(logFile);
|
||||||
if (fileName.Length >= 4 && fileName.StartsWith("log_", StringComparison.Ordinal) &&
|
if (DateTime.TryParseExact(fileName.Substring(4), "yyyy-MM-dd", null, System.Globalization.DateTimeStyles.None, out var logDate)) {
|
||||||
DateTime.TryParseExact(fileName.Substring(4), "yyyy-MM-dd", null, System.Globalization.DateTimeStyles.None, out var logDate)) {
|
|
||||||
if (logDate < expirationDate) {
|
if (logDate < expirationDate) {
|
||||||
File.Delete(logFile);
|
File.Delete(logFile);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,8 +8,7 @@ public class FileLoggerProvider : ILoggerProvider {
|
|||||||
private readonly TimeSpan _retentionPeriod;
|
private readonly TimeSpan _retentionPeriod;
|
||||||
|
|
||||||
public FileLoggerProvider(string folderPath, TimeSpan? retentionPeriod = null) {
|
public FileLoggerProvider(string folderPath, TimeSpan? retentionPeriod = null) {
|
||||||
ArgumentNullException.ThrowIfNull(folderPath);
|
_folderPath = folderPath ?? throw new ArgumentNullException(nameof(folderPath));
|
||||||
_folderPath = folderPath;
|
|
||||||
_retentionPeriod = retentionPeriod ?? TimeSpan.FromDays(7); // Default retention period is 7 days
|
_retentionPeriod = retentionPeriod ?? TimeSpan.FromDays(7); // Default retention period is 7 days
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,8 +8,7 @@ public class JsonFileLoggerProvider : ILoggerProvider {
|
|||||||
private readonly TimeSpan _retentionPeriod;
|
private readonly TimeSpan _retentionPeriod;
|
||||||
|
|
||||||
public JsonFileLoggerProvider(string folderPath, TimeSpan? retentionPeriod = null) {
|
public JsonFileLoggerProvider(string folderPath, TimeSpan? retentionPeriod = null) {
|
||||||
ArgumentNullException.ThrowIfNull(folderPath);
|
_folderPath = folderPath ?? throw new ArgumentNullException(nameof(folderPath));
|
||||||
_folderPath = folderPath;
|
|
||||||
_retentionPeriod = retentionPeriod ?? TimeSpan.FromDays(7); // Default retention period is 7 days
|
_retentionPeriod = retentionPeriod ?? TimeSpan.FromDays(7); // Default retention period is 7 days
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<!-- NuGet package metadata -->
|
<!-- NuGet package metadata -->
|
||||||
<PackageId>MaksIT.Core</PackageId>
|
<PackageId>MaksIT.Core</PackageId>
|
||||||
<Version>1.6.5</Version>
|
<Version>1.6.4</Version>
|
||||||
<Authors>Maksym Sadovnychyy</Authors>
|
<Authors>Maksym Sadovnychyy</Authors>
|
||||||
<Company>MAKS-IT</Company>
|
<Company>MAKS-IT</Company>
|
||||||
<Product>MaksIT.Core</Product>
|
<Product>MaksIT.Core</Product>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@ -27,9 +27,9 @@ public class NetworkConnection : IDisposable {
|
|||||||
throw new PlatformNotSupportedException("NetworkConnection is only supported on Windows.");
|
throw new PlatformNotSupportedException("NetworkConnection is only supported on Windows.");
|
||||||
}
|
}
|
||||||
|
|
||||||
ArgumentNullException.ThrowIfNull(logger);
|
if (logger == null) throw new ArgumentNullException(nameof(logger));
|
||||||
ArgumentNullException.ThrowIfNull(networkName);
|
if (networkName == null) throw new ArgumentNullException(nameof(networkName));
|
||||||
ArgumentNullException.ThrowIfNull(credentials);
|
if (credentials == null) throw new ArgumentNullException(nameof(credentials));
|
||||||
|
|
||||||
var netResource = new NetResource {
|
var netResource = new NetResource {
|
||||||
Scope = ResourceScope.GlobalNetwork,
|
Scope = ResourceScope.GlobalNetwork,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
|
||||||
@ -14,8 +14,9 @@ public static class Base32Encoder {
|
|||||||
[NotNullWhen(false)] out string? errorMessage
|
[NotNullWhen(false)] out string? errorMessage
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
ArgumentNullException.ThrowIfNull(data);
|
if (data == null || data.Length == 0) {
|
||||||
if (data.Length == 0) throw new ArgumentException("Data cannot be empty.", nameof(data));
|
throw new ArgumentNullException(nameof(data));
|
||||||
|
}
|
||||||
|
|
||||||
var result = new StringBuilder();
|
var result = new StringBuilder();
|
||||||
int buffer = data[0];
|
int buffer = data[0];
|
||||||
@ -65,8 +66,9 @@ public static class Base32Encoder {
|
|||||||
[NotNullWhen(false)] out string? errorMessage
|
[NotNullWhen(false)] out string? errorMessage
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
ArgumentNullException.ThrowIfNull(base32);
|
if (string.IsNullOrEmpty(base32)) {
|
||||||
if (string.IsNullOrWhiteSpace(base32)) throw new ArgumentException("Base32 string cannot be empty.", nameof(base32));
|
throw new ArgumentNullException(nameof(base32));
|
||||||
|
}
|
||||||
|
|
||||||
base32 = base32.TrimEnd(PaddingChar.ToCharArray());
|
base32 = base32.TrimEnd(PaddingChar.ToCharArray());
|
||||||
int byteCount = base32.Length * 5 / 8;
|
int byteCount = base32.Length * 5 / 8;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
namespace MaksIT.Core.Security;
|
namespace MaksIT.Core.Security;
|
||||||
@ -132,8 +132,8 @@ public static class TotpGenerator {
|
|||||||
recoveryCodes = new List<string>();
|
recoveryCodes = new List<string>();
|
||||||
|
|
||||||
for (int i = 0; i < defaultCodeCount; i++) {
|
for (int i = 0; i < defaultCodeCount; i++) {
|
||||||
var code = Guid.NewGuid().ToString("N")[..8]; // Generate an 8-character code
|
var code = Guid.NewGuid().ToString("N").Substring(0, 8); // Generate an 8-character code
|
||||||
var formattedCode = $"{code[..4]}-{code[4..8]}"; // Format as XXXX-XXXX
|
var formattedCode = $"{code.Substring(0, 4)}-{code.Substring(4, 4)}"; // Format as XXXX-XXXX
|
||||||
recoveryCodes.Add(formattedCode);
|
recoveryCodes.Add(formattedCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,9 @@
|
|||||||
using System.Linq.Dynamic.Core;
|
using System.Linq.Dynamic.Core;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using MaksIT.Core.Abstractions.Webapi;
|
using MaksIT.Core.Abstractions.Webapi;
|
||||||
using MaksIT.Core.Extensions;
|
using MaksIT.Core.Extensions;
|
||||||
|
|
||||||
namespace MaksIT.Core.Webapi.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Base request model for paged list operations with optional filter and sort.
|
|
||||||
/// BuildFilterExpression produces expressions suitable for IQueryable/EF Core (single parameter, translatable operations).
|
|
||||||
/// </summary>
|
|
||||||
public class PagedRequest : RequestModelBase {
|
public class PagedRequest : RequestModelBase {
|
||||||
public int PageSize { get; set; } = 100;
|
public int PageSize { get; set; } = 100;
|
||||||
public int PageNumber { get; set; } = 1;
|
public int PageNumber { get; set; } = 1;
|
||||||
@ -22,11 +16,6 @@ public class PagedRequest : RequestModelBase {
|
|||||||
return BuildFilterExpression<T>(Filters);
|
return BuildFilterExpression<T>(Filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses the filter string into an Expression<Func<T, bool>> for use with IQueryable/EF Core.
|
|
||||||
/// Uses a single parameter; avoid filter syntax that would require non-translatable operations.
|
|
||||||
/// Supported: property access, ==, !=, &&, ||, !, Contains, StartsWith, EndsWith, ToLower() for strings.
|
|
||||||
/// </summary>
|
|
||||||
public virtual Expression<Func<T, bool>> BuildFilterExpression<T>(string? filters) {
|
public virtual Expression<Func<T, bool>> BuildFilterExpression<T>(string? filters) {
|
||||||
if (string.IsNullOrWhiteSpace(filters))
|
if (string.IsNullOrWhiteSpace(filters))
|
||||||
return x => true; // Returns an expression that doesn't filter anything.
|
return x => true; // Returns an expression that doesn't filter anything.
|
||||||
|
|||||||
@ -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
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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%"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,3 @@
|
|||||||
#requires -Version 7.0
|
|
||||||
#requires -PSEdition Core
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Shared Git helpers for utility scripts.
|
# Shared Git helpers for utility scripts.
|
||||||
#
|
#
|
||||||
|
|||||||
@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
3
utils/Release-NuGetPackage/Release-NuGetPackage.bat
Normal file
3
utils/Release-NuGetPackage/Release-NuGetPackage.bat
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@echo off
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Release-NuGetPackage.ps1"
|
||||||
|
pause
|
||||||
766
utils/Release-NuGetPackage/Release-NuGetPackage.ps1
Normal file
766
utils/Release-NuGetPackage/Release-NuGetPackage.ps1
Normal file
@ -0,0 +1,766 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Builds, tests, packs, and publishes MaksIT.Core to NuGet and GitHub releases.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script automates the release process for MaksIT.Core library.
|
||||||
|
The script is IDEMPOTENT - you can safely re-run it if any step fails.
|
||||||
|
It will skip already-completed steps (NuGet and GitHub) and only create what's missing.
|
||||||
|
GitHub repository target can be configured explicitly in scriptsettings.json.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Validates environment and prerequisites
|
||||||
|
- Checks if version already exists on NuGet.org (skips if released)
|
||||||
|
- Checks if GitHub release exists (skips if released)
|
||||||
|
- Scans for vulnerable packages (security check)
|
||||||
|
- Builds and tests the project (Windows + Linux via Docker)
|
||||||
|
- Collects code coverage with Coverlet (threshold enforcement optional)
|
||||||
|
- Generates test result artifacts (TRX format) and coverage reports
|
||||||
|
- Displays test results with pass/fail counts and coverage percentage
|
||||||
|
- Publishes to NuGet.org
|
||||||
|
- Creates a GitHub release with changelog and package assets
|
||||||
|
- Shows timing summary for all steps
|
||||||
|
|
||||||
|
.REQUIREMENTS
|
||||||
|
Environment Variables:
|
||||||
|
- NUGET_MAKS_IT : NuGet.org API key for publishing packages
|
||||||
|
- GITHUB_MAKS_IT_COM : GitHub Personal Access Token (needs 'repo' scope)
|
||||||
|
|
||||||
|
Tools (Required):
|
||||||
|
- dotnet CLI : For building, testing, and packing
|
||||||
|
- git : For version control operations
|
||||||
|
- gh (GitHub CLI) : For creating GitHub releases
|
||||||
|
- docker : For cross-platform Linux testing
|
||||||
|
|
||||||
|
.WORKFLOW
|
||||||
|
1. VALIDATION PHASE
|
||||||
|
- Check required environment variables (NuGet key, GitHub token)
|
||||||
|
- Check required tools are installed (dotnet, git, gh, docker)
|
||||||
|
- Verify no uncommitted changes in working directory
|
||||||
|
- Authenticate GitHub CLI
|
||||||
|
|
||||||
|
2. VERSION & RELEASE CHECK PHASE (Idempotent)
|
||||||
|
- Read latest version from CHANGELOG.md
|
||||||
|
- Find commit with matching version tag
|
||||||
|
- Validate tag is on configured release branch (from scriptsettings.json)
|
||||||
|
- Check if already released on NuGet.org (mark for skip if yes)
|
||||||
|
- Check if GitHub release exists (mark for skip if yes)
|
||||||
|
- Read target framework from MaksIT.Core.csproj
|
||||||
|
- Extract release notes from CHANGELOG.md for current version
|
||||||
|
|
||||||
|
3. SECURITY SCAN
|
||||||
|
- Check for vulnerable packages (dotnet list package --vulnerable)
|
||||||
|
- Fail or warn based on $failOnVulnerabilities setting
|
||||||
|
|
||||||
|
4. BUILD & TEST PHASE
|
||||||
|
- Clean previous builds (delete bin/obj folders)
|
||||||
|
- Restore NuGet packages
|
||||||
|
- Windows: Build main project -> Build test project -> Run tests with coverage
|
||||||
|
- Analyze code coverage (fail if below threshold when configured)
|
||||||
|
- Linux (Docker): Build main project -> Build test project -> Run tests (TRX report)
|
||||||
|
- Rebuild for Windows (Docker may overwrite bin/obj)
|
||||||
|
- Create NuGet package (.nupkg) and symbols (.snupkg)
|
||||||
|
- All steps are timed for performance tracking
|
||||||
|
|
||||||
|
5. CONFIRMATION PHASE
|
||||||
|
- Display release summary
|
||||||
|
- Prompt user for confirmation before proceeding
|
||||||
|
|
||||||
|
6. NUGET RELEASE PHASE (Idempotent)
|
||||||
|
- Skip if version already exists on NuGet.org
|
||||||
|
- Otherwise, push package to NuGet.org
|
||||||
|
|
||||||
|
7. GITHUB RELEASE PHASE (Idempotent)
|
||||||
|
- Skip if release already exists
|
||||||
|
- Push tag to remote if not already there
|
||||||
|
- Create GitHub release with:
|
||||||
|
* Release notes from CHANGELOG.md
|
||||||
|
* .nupkg and .snupkg as downloadable assets
|
||||||
|
|
||||||
|
8. COMPLETION PHASE
|
||||||
|
- Display timing summary for all steps
|
||||||
|
- Display test results summary
|
||||||
|
- Display success summary with links
|
||||||
|
- Open NuGet and GitHub release pages in browser
|
||||||
|
- TODO: Email notification (template provided)
|
||||||
|
- TODO: Package signing (template provided)
|
||||||
|
|
||||||
|
.USAGE
|
||||||
|
Before running:
|
||||||
|
1. Ensure Docker Desktop is running (for Linux tests)
|
||||||
|
2. Update version in MaksIT.Core.csproj
|
||||||
|
3. Run .\Generate-Changelog.ps1 to update CHANGELOG.md and LICENSE.md
|
||||||
|
4. Review and commit all changes
|
||||||
|
5. Create version tag: git tag v1.x.x
|
||||||
|
6. Run: .\Release-NuGetPackage.ps1
|
||||||
|
|
||||||
|
Note: The script finds the commit with the tag matching CHANGELOG.md version.
|
||||||
|
You can run it from any branch/commit - it releases the tagged commit.
|
||||||
|
|
||||||
|
Re-run release (idempotent - skips NuGet/GitHub if already released):
|
||||||
|
.\Release-NuGetPackage.ps1
|
||||||
|
|
||||||
|
Generate changelog and update LICENSE year:
|
||||||
|
.\Generate-Changelog.ps1
|
||||||
|
|
||||||
|
.CONFIGURATION
|
||||||
|
All settings are stored in scriptsettings.json:
|
||||||
|
- packageSigning: Code signing certificate configuration
|
||||||
|
- emailNotification: SMTP settings for release notifications
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Author: Maksym Sadovnychyy (MAKS-IT)
|
||||||
|
Repository: https://github.com/MAKS-IT-COM/maksit-core
|
||||||
|
#>
|
||||||
|
|
||||||
|
# No parameters - behavior is controlled by current branch (configured in scriptsettings.json):
|
||||||
|
# - dev branch -> Local build only (no tag required, uncommitted changes allowed)
|
||||||
|
# - release branch -> Full release to GitHub (tag required, clean working directory)
|
||||||
|
|
||||||
|
# Get the directory of the current script (for loading settings and relative paths)
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
|
||||||
|
#region Import Modules
|
||||||
|
|
||||||
|
# Import TestRunner module
|
||||||
|
$utilsDir = Split-Path $scriptDir -Parent
|
||||||
|
|
||||||
|
$testRunnerModulePath = Join-Path $utilsDir "TestRunner.psm1"
|
||||||
|
if (-not (Test-Path $testRunnerModulePath)) {
|
||||||
|
Write-Error "TestRunner module not found at: $testRunnerModulePath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Import-Module $testRunnerModulePath -Force
|
||||||
|
|
||||||
|
# Import ScriptConfig module
|
||||||
|
$scriptConfigModulePath = Join-Path $utilsDir "ScriptConfig.psm1"
|
||||||
|
if (-not (Test-Path $scriptConfigModulePath)) {
|
||||||
|
Write-Error "ScriptConfig module not found at: $scriptConfigModulePath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Import-Module $scriptConfigModulePath -Force
|
||||||
|
|
||||||
|
# Import Logging module
|
||||||
|
$loggingModulePath = Join-Path $utilsDir "Logging.psm1"
|
||||||
|
if (-not (Test-Path $loggingModulePath)) {
|
||||||
|
Write-Error "Logging module not found at: $loggingModulePath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Import-Module $loggingModulePath -Force
|
||||||
|
|
||||||
|
|
||||||
|
# Import GitTools module
|
||||||
|
$gitToolsModulePath = Join-Path $utilsDir "GitTools.psm1"
|
||||||
|
if (-not (Test-Path $gitToolsModulePath)) {
|
||||||
|
Write-Error "GitTools module not found at: $gitToolsModulePath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Import-Module $gitToolsModulePath -Force
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Load Settings
|
||||||
|
$settings = Get-ScriptSettings -ScriptDir $scriptDir
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Configuration
|
||||||
|
|
||||||
|
# GitHub configuration
|
||||||
|
$githubReleseEnabled = $settings.github.enabled
|
||||||
|
$githubTokenEnvVar = $settings.github.githubToken
|
||||||
|
$githubToken = [System.Environment]::GetEnvironmentVariable($githubTokenEnvVar)
|
||||||
|
$githubRepositorySetting = $settings.github.repository
|
||||||
|
|
||||||
|
# NuGet configuration
|
||||||
|
$nugetReleseEnabled = $settings.nuget.enabled
|
||||||
|
$nugetApiKeyEnvVar = $settings.nuget.nugetApiKey
|
||||||
|
$nugetApiKey = [System.Environment]::GetEnvironmentVariable($nugetApiKeyEnvVar)
|
||||||
|
$nugetSource = if ($settings.nuget.source) { $settings.nuget.source } else { "https://api.nuget.org/v3/index.json" }
|
||||||
|
|
||||||
|
# Paths from settings (resolve relative to script directory)
|
||||||
|
$csprojPaths = @()
|
||||||
|
$rawCsprojPaths = @()
|
||||||
|
|
||||||
|
if ($settings.paths.csprojPaths) {
|
||||||
|
if ($settings.paths.csprojPaths -is [System.Collections.IEnumerable] -and -not ($settings.paths.csprojPaths -is [string])) {
|
||||||
|
$rawCsprojPaths += $settings.paths.csprojPaths
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$rawCsprojPaths += $settings.paths.csprojPaths
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Error "No csproj path configured. Set 'paths.csprojPaths' (preferred) or 'paths.csprojPath' in scriptsettings.json."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($path in $rawCsprojPaths) {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($path)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $path))
|
||||||
|
$csprojPaths += $resolvedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($csprojPaths.Count -eq 0) {
|
||||||
|
Write-Error "No valid csproj paths configured in scriptsettings.json."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$testResultsDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.testResultsDir))
|
||||||
|
$stagingDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.stagingDir))
|
||||||
|
$releaseDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.releaseDir))
|
||||||
|
$changelogPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.changelogPath))
|
||||||
|
$testProjectPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.testProject))
|
||||||
|
|
||||||
|
# Release naming patterns
|
||||||
|
$zipNamePattern = $settings.release.zipNamePattern
|
||||||
|
$releaseTitlePattern = $settings.release.releaseTitlePattern
|
||||||
|
|
||||||
|
# Branch configuration
|
||||||
|
$releaseBranch = $settings.branches.release
|
||||||
|
$devBranch = $settings.branches.dev
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Helpers
|
||||||
|
|
||||||
|
# Helper: extract a csproj property (first match)
|
||||||
|
function Get-CsprojPropertyValue {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)][xml]$csproj,
|
||||||
|
[Parameter(Mandatory=$true)][string]$propertyName
|
||||||
|
)
|
||||||
|
|
||||||
|
$propNode = $csproj.Project.PropertyGroup |
|
||||||
|
Where-Object { $_.$propertyName } |
|
||||||
|
Select-Object -First 1
|
||||||
|
|
||||||
|
if ($propNode) {
|
||||||
|
return $propNode.$propertyName
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Helper: resolve output assembly name for published exe
|
||||||
|
function Resolve-ProjectExeName {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)][string]$projPath
|
||||||
|
)
|
||||||
|
|
||||||
|
[xml]$csproj = Get-Content $projPath
|
||||||
|
$assemblyName = Get-CsprojPropertyValue -csproj $csproj -propertyName "AssemblyName"
|
||||||
|
if ($assemblyName) {
|
||||||
|
return $assemblyName
|
||||||
|
}
|
||||||
|
|
||||||
|
return [System.IO.Path]::GetFileNameWithoutExtension($projPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Helper: check for uncommitted changes
|
||||||
|
function Assert-WorkingTreeClean {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[bool]$IsReleaseBranch
|
||||||
|
)
|
||||||
|
|
||||||
|
$gitStatus = Get-GitStatusShort
|
||||||
|
if ($gitStatus) {
|
||||||
|
if ($IsReleaseBranch) {
|
||||||
|
Write-Error "Working directory has uncommitted changes. Commit or stash them before releasing."
|
||||||
|
Write-Log -Level "WARN" -Message "Uncommitted files:"
|
||||||
|
$gitStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" }
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Log -Level "WARN" -Message " Uncommitted changes detected (allowed on dev branch)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Log -Level "OK" -Message " Working directory is clean."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Helper: read versions from csproj files
|
||||||
|
function Get-CsprojVersions {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string[]]$CsprojPaths
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Log -Level "INFO" -Message "Reading version(s) from csproj(s)..."
|
||||||
|
$projectVersions = @{}
|
||||||
|
|
||||||
|
foreach ($projPath in $CsprojPaths) {
|
||||||
|
if (-not (Test-Path $projPath -PathType Leaf)) {
|
||||||
|
Write-Error "Csproj file not found at: $projPath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([System.IO.Path]::GetExtension($projPath) -ne ".csproj") {
|
||||||
|
Write-Error "Configured path is not a .csproj file: $projPath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[xml]$csproj = Get-Content $projPath
|
||||||
|
$version = Get-CsprojPropertyValue -csproj $csproj -propertyName "Version"
|
||||||
|
|
||||||
|
if (-not $version) {
|
||||||
|
Write-Error "Version not found in $projPath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$projectVersions[$projPath] = $version
|
||||||
|
Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($projPath)): $version"
|
||||||
|
}
|
||||||
|
|
||||||
|
return $projectVersions
|
||||||
|
}
|
||||||
|
|
||||||
|
# Helper: resolve GitHub repository (owner/repo) from settings override or remote URL
|
||||||
|
function Resolve-GitHubRepository {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$RepositorySetting
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($RepositorySetting)) {
|
||||||
|
$value = $RepositorySetting.Trim()
|
||||||
|
|
||||||
|
if ($value -match '^https?://github\.com/(?<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."
|
||||||
|
}
|
||||||
|
#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
|
||||||
67
utils/Release-NuGetPackage/scriptsettings.json
Normal file
67
utils/Release-NuGetPackage/scriptsettings.json
Normal 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-core"
|
||||||
|
},
|
||||||
|
|
||||||
|
"nuget": {
|
||||||
|
"enabled": true,
|
||||||
|
"nugetApiKey": "NUGET_MAKS_IT",
|
||||||
|
"source": "https://api.nuget.org/v3/index.json"
|
||||||
|
},
|
||||||
|
|
||||||
|
"branches": {
|
||||||
|
"release": "main",
|
||||||
|
"dev": "dev"
|
||||||
|
},
|
||||||
|
|
||||||
|
"paths": {
|
||||||
|
"csprojPaths": [
|
||||||
|
"..\\..\\src\\MaksIT.Core\\MaksIT.Core.csproj"
|
||||||
|
],
|
||||||
|
"testResultsDir": "..\\..\\testResults",
|
||||||
|
"stagingDir": "..\\..\\staging",
|
||||||
|
"releaseDir": "..\\..\\release",
|
||||||
|
"changelogPath": "..\\..\\CHANGELOG.md",
|
||||||
|
"testProject": "..\\..\\src\\MaksIT.Core.Tests"
|
||||||
|
},
|
||||||
|
|
||||||
|
"release": {
|
||||||
|
"zipNamePattern": "maksit.core-{version}.zip",
|
||||||
|
"releaseTitlePattern": "Release {version}"
|
||||||
|
},
|
||||||
|
|
||||||
|
"_comments": {
|
||||||
|
"github": {
|
||||||
|
"enabled": "Enable/disable GitHub release creation.",
|
||||||
|
"githubToken": "Environment variable name containing GitHub token used by gh CLI.",
|
||||||
|
"repository": "GitHub repository override used for releases (supports owner/repo or full GitHub URL)."
|
||||||
|
},
|
||||||
|
"nuget": {
|
||||||
|
"enabled": "Enable/disable NuGet publish step.",
|
||||||
|
"nugetApiKey": "Environment variable name containing NuGet API key.",
|
||||||
|
"source": "NuGet feed URL passed to dotnet nuget push."
|
||||||
|
},
|
||||||
|
"branches": {
|
||||||
|
"release": "Branch that requires tag and allows full publish flow.",
|
||||||
|
"dev": "Branch for local/dev build flow (no tag required)."
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"csprojPaths": "List of project files used for version discovery and publish output.",
|
||||||
|
"testResultsDir": "Directory where test artifacts are written.",
|
||||||
|
"stagingDir": "Temporary staging directory before archive creation.",
|
||||||
|
"releaseDir": "Output directory for release archives and artifacts.",
|
||||||
|
"changelogPath": "Path to CHANGELOG.md used for version and release notes extraction.",
|
||||||
|
"testProject": "Test project path used by TestRunner."
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"zipNamePattern": "Archive name pattern. Supports {version} placeholder.",
|
||||||
|
"releaseTitlePattern": "GitHub release title pattern. Supports {version} placeholder."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -1 +0,0 @@
|
|||||||
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
@echo off
|
|
||||||
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Release-Package.ps1"
|
|
||||||
pause
|
|
||||||
@ -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
|
|
||||||
@ -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.Core.Tests",
|
|
||||||
"resultsDir": "..\\..\\testResults"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "QualityGate",
|
|
||||||
"Stage": "QualityGate",
|
|
||||||
"Enabled": true,
|
|
||||||
"coverageThreshold": 0,
|
|
||||||
"failOnVulnerabilities": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "DotNetPack",
|
|
||||||
"Stage": "Build",
|
|
||||||
"Enabled": true,
|
|
||||||
"projectFiles": [
|
|
||||||
"..\\..\\src\\MaksIT.Core\\MaksIT.Core.csproj"
|
|
||||||
],
|
|
||||||
"artifactsDir": "..\\..\\release"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "CreateArchive",
|
|
||||||
"Stage": "Build",
|
|
||||||
"Enabled": true,
|
|
||||||
"zipNamePattern": "maksit.core-{version}.zip"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "GitHub",
|
|
||||||
"Stage": "Release",
|
|
||||||
"Enabled": true,
|
|
||||||
"branches": [
|
|
||||||
"main"
|
|
||||||
],
|
|
||||||
"githubToken": "GITHUB_MAKS_IT_COM",
|
|
||||||
"repository": "https://github.com/MAKS-IT-COM/maksit-core",
|
|
||||||
"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'])."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)]
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
@echo off
|
|
||||||
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Update-RepoUtils.ps1"
|
|
||||||
pause
|
|
||||||
@ -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
|
|
||||||
@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user