Compare commits

..

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

45 changed files with 1177 additions and 1712 deletions

View File

@ -1,34 +0,0 @@
name: Publish to NuGet
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v5
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
- name: Pack
run: |
dotnet pack src/MaksIT.Persistence/MaksIT.Persistence.csproj -c Release -o artifacts
- name: NuGet login (OIDC)
id: login
uses: NuGet/login@v1
with:
user: maks-it
- name: Push to NuGet
run: dotnet nuget push artifacts/*.nupkg --api-key ${{ steps.login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate

View File

@ -7,20 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [2.0.1] - 2026-10-27
### Changed
- Bumped package dependencies: linq2db / linq2db.PostgreSQL 6.5.1, MaksIT.Core 1.6.14, MaksIT.Results 2.0.6, Microsoft.Extensions.* 10.0.14 (tests: coverlet.mtp, xunit.v3).
- Synced RepoUtils utils: ContainerRegistry JSON catalog (PascalCase Harbor / InCluster keys); `CleanupArtifacts` replaces `DotNetCleanupArtifacts`; GitHub `githubSecret` is `GitClone`; release `scriptSettings` declares `repoUtilsSecretsShared` / `repoUtilsSecrets`; dropped unused Npm plugins from release settings.
- NuGet publish moved to GitHub Actions Trusted Publishing (OIDC on `v*` tags); removed `DotNetNuGet` from the RepoUtils release pipeline.
- Docs: CONTRIBUTING points at RepoUtils Origin; README HAMode note is **maksit-buoy**.
## [2.0.0] - 2026-08-21
### Removed
- **Schema sync (breaking):** `RegisterSchemaSync`, `ISchemaSyncService`, `ISchemaSyncOptions`, `SchemaColumn`, desired-schema maps, `DatabaseStartupPhases.SchemaSync`, and add-only `ALTER TABLE` sync. Schema evolution is FluentMigrator only.
## [1.0.0] - 2026-08-17 ## [1.0.0] - 2026-08-17
### Added ### Added

View File

@ -90,11 +90,9 @@ This project follows [Semantic Versioning](https://semver.org/):
- **MINOR**: backward-compatible features - **MINOR**: backward-compatible features
- **PATCH**: backward-compatible fixes - **PATCH**: backward-compatible fixes
Optional SemVer prerelease (`0.1.0-alpha.1`, `0.1.0-beta.1`, `0.1.0-rc.1`) is valid; git tag is `v{version}`. GitHub marks hyphenated versions as prerelease.
## Utility Scripts ## Utility Scripts
Automation lives under `utils/` (Community profile from [maksit-repoutils](https://git.maks-it.com/MAKS-IT/maksit-repoutils), local-copy sync). Product-specific settings are in each engine’s `scriptSettings.json`. Automation lives under `utils/` (Community profile from [maksit-repoutils](https://github.com/MAKS-IT-COM/maksit-repoutils), local-copy sync). Product-specific settings are in each engine’s `scriptSettings.json`.
### Run tests and coverage badges ### Run tests and coverage badges

View File

@ -1,10 +1,10 @@
# MaksIT.Persistence # MaksIT.Persistence
![Line Coverage](https://img.shields.io/badge/Line%20Coverage-38.8%25-yellow) ![Line Coverage](https://img.shields.io/badge/Line%20Coverage-36.8%25-yellow)
![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-41.7%25-yellowgreen) ![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-41.4%25-yellowgreen)
![Method Coverage](https://img.shields.io/badge/Method%20Coverage-50%25-yellowgreen) ![Method Coverage](https://img.shields.io/badge/Method%20Coverage-48.6%25-yellowgreen)
NuGet facade over [linq2db](https://linq2db.github.io/) + PostgreSQL for ASP.NET Core Engines: connection factory, FluentMigrator startup, and `MaksIT.Results` guards. NuGet facade over [linq2db](https://linq2db.github.io/) + PostgreSQL for ASP.NET Core Engines: connection factory, FluentMigrator startup, add-only schema sync, and `MaksIT.Results` guards.
## Table of Contents ## Table of Contents
@ -22,14 +22,15 @@ NuGet facade over [linq2db](https://linq2db.github.io/) + PostgreSQL for ASP.NET
`MaksIT.Persistence` wraps linq2db PostgreSQL wiring so Engine code depends on `ILinq2DbConnectionFactory` instead of copying `DataOptions` / `ProviderName.PostgreSQL` / startup wait in every product. Domain tables, Dto, FluentMapping **content**, and FluentMigrator migration classes stay in the product Engine. `MaksIT.Persistence` wraps linq2db PostgreSQL wiring so Engine code depends on `ILinq2DbConnectionFactory` instead of copying `DataOptions` / `ProviderName.PostgreSQL` / startup wait in every product. Domain tables, Dto, FluentMapping **content**, and FluentMigrator migration classes stay in the product Engine.
Linq2Db queries stay in the product: persist/query services still call `GetTable<T>()`. This library hides connection lifetime, Postgres transients, and FluentMigrator bootstrap. Linq2Db queries stay in the product: persist/query services still call `GetTable<T>()`. This library hides connection lifetime, Postgres transients, migration bootstrap, and add-only schema sync.
| Need | Register | Inject | | Need | Register | Inject |
|------|----------|--------| |------|----------|--------|
| Scoped `DataConnection` | `RegisterLinq2Db(connectionString, mappingSchema?)` | `ILinq2DbConnectionFactory` | | Scoped `DataConnection` | `RegisterLinq2Db(connectionString, mappingSchema?)` | `ILinq2DbConnectionFactory` |
| FluentMigrator wait / create-DB / retry MigrateUp | `RegisterPostgresMigrations(connectionString, assembly, options?)` | `IRunMigrationsService` | | FluentMigrator wait / create-DB / retry MigrateUp | `RegisterPostgresMigrations(connectionString, assembly, options?)` | `IRunMigrationsService` |
| Add-only column sync | `RegisterSchemaSync(connectionString, desiredSchema, autoSync)` | `ISchemaSyncService` |
Work leases are **not** in this package: use **MaksIT.Dapr** (`RegisterWorkLeases`) for most first-party apps, or **MaksIT.HAMode** for **maksit-buoy** only. Work leases are **not** in this package: use **MaksIT.Dapr** (`RegisterWorkLeases`) for most first-party apps, or **MaksIT.HAMode** for **maksit-nats** only.
## Result conventions ## Result conventions
@ -54,7 +55,7 @@ dotnet add package MaksIT.Persistence
Or in your `.csproj`: Or in your `.csproj`:
```xml ```xml
<PackageReference Include="MaksIT.Persistence" Version="2.0.1" /> <PackageReference Include="MaksIT.Persistence" Version="1.0.0" />
``` ```
## Registering services ## Registering services
@ -72,9 +73,10 @@ services.RegisterPostgresMigrations(
EnsureCoordinationTables = ct => CoordinationTableProvisioner.EnsureAsync(config.ConnectionString, ct), EnsureCoordinationTables = ct => CoordinationTableProvisioner.EnsureAsync(config.ConnectionString, ct),
VerifySchema = ct => VerifyCoreSchemaAsync(config.ConnectionString, ct), VerifySchema = ct => VerifyCoreSchemaAsync(config.ConnectionString, ct),
}); });
services.RegisterSchemaSync(config.ConnectionString, ProductDesiredSchema.Map, config.AutoSyncSchema);
``` ```
Schema changes go in a new FluentMigrator `[Migration]` (`Create.Table` / `Alter.Table.AddColumn`; Drop/rename needs a separate decision). Host `Program.cs` before `RunAsync`: Host `Program.cs` before `RunAsync`:
```csharp ```csharp
await app.Services.EnsureLinq2DbMigratedAsync(); await app.Services.EnsureLinq2DbMigratedAsync();
@ -90,6 +92,7 @@ var row = db.GetTable<UserDto>().FirstOrDefault(u => u.Id == id);
## What stays in the product ## What stays in the product
- Table names, Dto, FluentMapping entity list, FluentMigrator `[Migration]` classes - Table names, Dto, FluentMapping entity list, FluentMigrator `[Migration]` classes
- Desired schema map for SchemaSync
- Coordination DDL unique to the product (hook on `PostgresMigrationOptions`) - Coordination DDL unique to the product (hook on `PostgresMigrationOptions`)
- `I*PersistenceService` / `I*QueryService` and domain services - `I*PersistenceService` / `I*QueryService` and domain services
- HA work leases (Dapr or HAMode) - HA work leases (Dapr or HAMode)

View File

@ -12,11 +12,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.MTP" Version="10.1.0" /> <PackageReference Include="coverlet.MTP" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.14" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.14" /> <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.11" />
<PackageReference Include="Moq" Version="4.*" /> <PackageReference Include="Moq" Version="4.*" />
<PackageReference Include="xunit.v3" Version="4.0.2" /> <PackageReference Include="xunit.v3" Version="4.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -0,0 +1,32 @@
using MaksIT.Persistence.Infrastructure;
namespace MaksIT.Persistence.Tests;
public class SchemaSyncDdlBuilderTests {
[Fact]
public void BuildAddOnlyDdl_AddsMissingColumns_SkipsExistingAndMissingTables() {
var desired = new Dictionary<string, IReadOnlyList<SchemaColumn>>(StringComparer.OrdinalIgnoreCase) {
["users"] = [
new SchemaColumn("id", "uuid"),
new SchemaColumn("email", "text", Nullable: true),
new SchemaColumn("is_active", "boolean"),
],
["ghost"] = [new SchemaColumn("id", "uuid")],
};
var current = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase) {
["users"] = new HashSet<string>(["id"], StringComparer.OrdinalIgnoreCase),
};
var ddl = SchemaSyncDdlBuilder.BuildAddOnlyDdl(desired, current);
Assert.Equal(2, ddl.Count);
Assert.Contains(ddl, s => s.Contains("email", StringComparison.Ordinal) && !s.Contains("NOT NULL", StringComparison.Ordinal));
Assert.Contains(ddl, s => s.Contains("is_active", StringComparison.Ordinal) && s.Contains("NOT NULL", StringComparison.Ordinal));
Assert.DoesNotContain(ddl, s => s.Contains("ghost", StringComparison.Ordinal));
}
[Fact]
public void Quote_EscapesEmbeddedQuotes() =>
Assert.Equal("\"a\"\"b\"", SchemaSyncDdlBuilder.Quote("a\"b"));
}

View File

@ -31,6 +31,19 @@ public class ServiceCollectionExtensionsTests {
Assert.Throws<ArgumentException>(() => services.RegisterLinq2Db(" ")); Assert.Throws<ArgumentException>(() => services.RegisterLinq2Db(" "));
} }
[Fact]
public void RegisterSchemaSync_RegistersService() {
var services = new ServiceCollection();
services.AddLogging();
IReadOnlyDictionary<string, IReadOnlyList<SchemaColumn>> desired = new Dictionary<string, IReadOnlyList<SchemaColumn>> {
["users"] = [new SchemaColumn("id", "uuid")],
};
services.RegisterSchemaSync("Host=localhost;Database=app;Username=u;Password=p", desired, autoSync: false);
Assert.Contains(services, d => d.ServiceType == typeof(ISchemaSyncService));
}
[Fact] [Fact]
public void RegisterPostgresMigrations_RegistersRunner() { public void RegisterPostgresMigrations_RegistersRunner() {
var services = new ServiceCollection(); var services = new ServiceCollection();

View File

@ -71,4 +71,26 @@ public static class ServiceCollectionExtensions {
.AddLogging(lb => lb.AddFluentMigratorConsole()); .AddLogging(lb => lb.AddFluentMigratorConsole());
services.AddScoped<IRunMigrationsService, RunMigrationsService>(); services.AddScoped<IRunMigrationsService, RunMigrationsService>();
} }
/// <summary>
/// Registers add-only schema sync. Desired schema maps stay in the product.
/// </summary>
public static void RegisterSchemaSync(
this IServiceCollection services,
string connectionString,
IReadOnlyDictionary<string, IReadOnlyList<SchemaColumn>> desiredSchema,
bool autoSync
) {
if (string.IsNullOrWhiteSpace(connectionString))
throw new ArgumentException("connectionString is required.", nameof(connectionString));
ArgumentNullException.ThrowIfNull(desiredSchema);
services.TryAddSingleton<IDatabaseStartupObserver, NoOpDatabaseStartupObserver>();
services.AddSingleton<ISchemaSyncOptions>(new SchemaSyncOptions {
ConnectionString = connectionString,
AutoSync = autoSync,
DesiredSchema = desiredSchema,
});
services.AddScoped<ISchemaSyncService, SchemaSyncService>();
}
} }

View File

@ -5,11 +5,11 @@ using MaksIT.Persistence.Infrastructure;
namespace MaksIT.Persistence.Extensions; namespace MaksIT.Persistence.Extensions;
/// <summary> /// <summary>
/// Host startup helpers for FluentMigrator. /// Host startup helpers for migrations and schema sync.
/// </summary> /// </summary>
public static class ServiceProviderExtensions { public static class ServiceProviderExtensions {
/// <summary> /// <summary>
/// Runs <see cref="IRunMigrationsService"/> when registered. /// Runs <see cref="IRunMigrationsService"/> then <see cref="ISchemaSyncService"/> when registered.
/// Call from Program.cs before <c>RunAsync</c>. /// Call from Program.cs before <c>RunAsync</c>.
/// </summary> /// </summary>
public static async Task EnsureLinq2DbMigratedAsync( public static async Task EnsureLinq2DbMigratedAsync(
@ -22,5 +22,9 @@ public static class ServiceProviderExtensions {
var run = sp.GetService<IRunMigrationsService>(); var run = sp.GetService<IRunMigrationsService>();
if (run is not null) if (run is not null)
await run.RunAsync(cancellationToken).ConfigureAwait(false); await run.RunAsync(cancellationToken).ConfigureAwait(false);
var schemaSync = sp.GetService<ISchemaSyncService>();
if (schemaSync is not null)
await schemaSync.SyncSchemaAsync(cancellationToken).ConfigureAwait(false);
} }
} }

View File

@ -1,7 +1,7 @@
namespace MaksIT.Persistence.Infrastructure; namespace MaksIT.Persistence.Infrastructure;
/// <summary> /// <summary>
/// Optional hooks for reporting database startup phase timing (migrations). /// Optional hooks for reporting database startup phase timing (migrations, schema sync).
/// </summary> /// </summary>
public interface IDatabaseStartupObserver { public interface IDatabaseStartupObserver {
/// <summary>Called when a named startup phase begins.</summary> /// <summary>Called when a named startup phase begins.</summary>
@ -14,7 +14,7 @@ public interface IDatabaseStartupObserver {
void OnPhaseFailed(string phase, TimeSpan elapsed, Exception exception); void OnPhaseFailed(string phase, TimeSpan elapsed, Exception exception);
} }
/// <summary>Well-known phase names reported during migrations.</summary> /// <summary>Well-known phase names reported during migrations and schema sync.</summary>
public static class DatabaseStartupPhases { public static class DatabaseStartupPhases {
/// <summary>Maintenance Postgres (database postgres) accepted connections.</summary> /// <summary>Maintenance Postgres (database postgres) accepted connections.</summary>
public const string PostgresMaintenanceReady = "postgres_maintenance_ready"; public const string PostgresMaintenanceReady = "postgres_maintenance_ready";
@ -33,6 +33,9 @@ public static class DatabaseStartupPhases {
/// <summary>Product schema verification completed.</summary> /// <summary>Product schema verification completed.</summary>
public const string SchemaVerified = "schema_verified"; public const string SchemaVerified = "schema_verified";
/// <summary>Add-only schema sync completed.</summary>
public const string SchemaSync = "schema_sync";
} }
/// <summary>No-op observer used when the host does not register one.</summary> /// <summary>No-op observer used when the host does not register one.</summary>

View File

@ -0,0 +1,13 @@
namespace MaksIT.Persistence.Infrastructure;
/// <summary>Options for <see cref="ISchemaSyncService"/>.</summary>
public interface ISchemaSyncOptions {
/// <summary>PostgreSQL connection string.</summary>
string ConnectionString { get; }
/// <summary>When false, <see cref="ISchemaSyncService.SyncSchemaAsync"/> is a no-op.</summary>
bool AutoSync { get; }
/// <summary>Desired table → columns map (product-owned).</summary>
IReadOnlyDictionary<string, IReadOnlyList<SchemaColumn>> DesiredSchema { get; }
}

View File

@ -0,0 +1,9 @@
namespace MaksIT.Persistence.Infrastructure;
/// <summary>
/// Syncs the database schema: add missing columns only (no DROP).
/// </summary>
public interface ISchemaSyncService {
/// <summary>Applies add-only DDL when auto-sync is enabled.</summary>
Task SyncSchemaAsync(CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,9 @@
namespace MaksIT.Persistence.Infrastructure;
/// <summary>
/// Desired column for add-only schema sync.
/// </summary>
/// <param name="Name">PostgreSQL column name (snake_case).</param>
/// <param name="PgType">PostgreSQL type, e.g. uuid, text, boolean.</param>
/// <param name="Nullable">When false, generated ADD COLUMN includes NOT NULL (no default).</param>
public sealed record SchemaColumn(string Name, string PgType, bool Nullable = false);

View File

@ -0,0 +1,35 @@
namespace MaksIT.Persistence.Infrastructure;
/// <summary>
/// Builds add-only ALTER TABLE statements. Does not CREATE or DROP tables.
/// </summary>
public static class SchemaSyncDdlBuilder {
/// <summary>
/// Returns ALTER TABLE ADD COLUMN IF NOT EXISTS for columns present in <paramref name="desired"/>
/// and missing from <paramref name="current"/>. Skips tables that do not exist yet.
/// </summary>
public static IReadOnlyList<string> BuildAddOnlyDdl(
IReadOnlyDictionary<string, IReadOnlyList<SchemaColumn>> desired,
IReadOnlyDictionary<string, HashSet<string>> current
) {
List<string> ddl = [];
foreach (var (table, columns) in desired) {
if (!current.TryGetValue(table, out var existingColumns))
continue;
foreach (var column in columns) {
if (existingColumns.Contains(column.Name))
continue;
var nullable = column.Nullable ? "" : " NOT NULL";
ddl.Add($"ALTER TABLE {Quote(table)} ADD COLUMN IF NOT EXISTS {Quote(column.Name)} {column.PgType}{nullable}");
}
}
return ddl;
}
/// <summary>Quotes a PostgreSQL identifier.</summary>
public static string Quote(string name) => $"\"{name.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
}

View File

@ -0,0 +1,13 @@
namespace MaksIT.Persistence.Infrastructure;
/// <inheritdoc />
public sealed class SchemaSyncOptions : ISchemaSyncOptions {
/// <inheritdoc />
public required string ConnectionString { get; init; }
/// <inheritdoc />
public required bool AutoSync { get; init; }
/// <inheritdoc />
public required IReadOnlyDictionary<string, IReadOnlyList<SchemaColumn>> DesiredSchema { get; init; }
}

View File

@ -0,0 +1,83 @@
using Microsoft.Extensions.Logging;
using Npgsql;
namespace MaksIT.Persistence.Infrastructure;
/// <inheritdoc />
public sealed class SchemaSyncService(
ISchemaSyncOptions options,
ILogger<SchemaSyncService> logger,
IDatabaseStartupObserver startupObserver
) : ISchemaSyncService {
/// <inheritdoc />
public async Task SyncSchemaAsync(CancellationToken cancellationToken = default) {
if (!options.AutoSync) {
startupObserver.OnPhaseStarted(DatabaseStartupPhases.SchemaSync);
startupObserver.OnPhaseCompleted(DatabaseStartupPhases.SchemaSync, TimeSpan.Zero);
return;
}
await DatabaseStartupPhaseRunner.RunAsync(
startupObserver,
DatabaseStartupPhases.SchemaSync,
RunSyncCoreAsync,
cancellationToken).ConfigureAwait(false);
}
async Task RunSyncCoreAsync(CancellationToken cancellationToken) {
logger.LogInformation("Schema sync (add-only) starting...");
var current = await GetCurrentSchemaAsync(cancellationToken).ConfigureAwait(false);
var ddl = SchemaSyncDdlBuilder.BuildAddOnlyDdl(options.DesiredSchema, current);
if (ddl.Count == 0) {
logger.LogInformation("Schema sync: no changes needed.");
return;
}
await using var conn = new NpgsqlConnection(options.ConnectionString);
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var tx = await conn.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
try {
foreach (var sql in ddl) {
await using var cmd = new NpgsqlCommand(sql, conn, tx);
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
logger.LogDebug("Executed: {Sql}", sql);
}
await tx.CommitAsync(cancellationToken).ConfigureAwait(false);
logger.LogInformation("Schema sync completed. Applied {Count} DDL statement(s).", ddl.Count);
}
catch {
await tx.RollbackAsync(cancellationToken).ConfigureAwait(false);
throw;
}
}
async Task<Dictionary<string, HashSet<string>>> GetCurrentSchemaAsync(CancellationToken ct) {
var result = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
await using var conn = new NpgsqlConnection(options.ConnectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand("""
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'public'
ORDER BY table_name, ordinal_position
""", conn);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false)) {
var table = reader.GetString(0);
var column = reader.GetString(1);
if (!result.TryGetValue(table, out var columns))
result[table] = columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
columns.Add(column);
}
return result;
}
}

View File

@ -7,11 +7,11 @@
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<PackageId>MaksIT.Persistence</PackageId> <PackageId>MaksIT.Persistence</PackageId>
<Version>2.0.1</Version> <Version>1.0.0</Version>
<Authors>Maksym Sadovnychyy</Authors> <Authors>Maksym Sadovnychyy</Authors>
<Company>MAKS-IT</Company> <Company>MAKS-IT</Company>
<Product>MaksIT.Persistence</Product> <Product>MaksIT.Persistence</Product>
<Description>MaksIT.Persistence product-agnostic facades for linq2db PostgreSQL: connection factory, FluentMigrator startup, and Result guards.</Description> <Description>MaksIT.Persistence product-agnostic facades for linq2db PostgreSQL: connection factory, FluentMigrator startup, add-only schema sync, and Result guards.</Description>
<PackageTags>dotnet;linq2db;postgresql;fluentmigrator;</PackageTags> <PackageTags>dotnet;linq2db;postgresql;fluentmigrator;</PackageTags>
<RepositoryUrl>https://github.com/MAKS-IT-COM/maksit-persistence</RepositoryUrl> <RepositoryUrl>https://github.com/MAKS-IT-COM/maksit-persistence</RepositoryUrl>
<License>MIT</License> <License>MIT</License>
@ -23,12 +23,12 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentMigrator" Version="8.0.1" /> <PackageReference Include="FluentMigrator" Version="8.0.1" />
<PackageReference Include="FluentMigrator.Runner.Postgres" Version="8.0.1" /> <PackageReference Include="FluentMigrator.Runner.Postgres" Version="8.0.1" />
<PackageReference Include="linq2db" Version="6.5.1" /> <PackageReference Include="linq2db" Version="6.4.0" />
<PackageReference Include="linq2db.PostgreSQL" Version="6.5.1" /> <PackageReference Include="linq2db.PostgreSQL" Version="6.4.0" />
<PackageReference Include="MaksIT.Core" Version="1.6.14" /> <PackageReference Include="MaksIT.Core" Version="1.6.9" />
<PackageReference Include="MaksIT.Results" Version="2.0.6" /> <PackageReference Include="MaksIT.Results" Version="2.0.4" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.14" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.14" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
<PackageReference Include="Npgsql" Version="10.0.3" /> <PackageReference Include="Npgsql" Version="10.0.3" />
</ItemGroup> </ItemGroup>

View File

@ -8,10 +8,6 @@
.NOTES .NOTES
Per-plugin dryRun on mutatesRemote plugins validates without remote mutation. Per-plugin dryRun on mutatesRemote plugins validates without remote mutation.
There is no engine-wide dryRun switch — set dryRun on each plugin as needed. There is no engine-wide dryRun switch — set dryRun on each plugin as needed.
-InstallOnly (Install-Helm-*.bat) keeps the version plugin and HelmSelfDeploy.
Compile, test, image build, image push, and HelmPush are skipped. The chart
must already be in OCI at the version the version plugin reports.
#> #>
# Keep a plain param block so unbound launcher arguments remain in $args for # Keep a plain param block so unbound launcher arguments remain in $args for
@ -21,17 +17,6 @@ param()
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
Set-Location $PSScriptRoot Set-Location $PSScriptRoot
$installOnly = $false
$extensionArgs = [System.Collections.Generic.List[object]]::new()
foreach ($argument in @($args)) {
if ([string]$argument -eq '-InstallOnly') {
$installOnly = $true
continue
}
$extensionArgs.Add($argument)
}
$srcDir = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $srcDir = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
. (Join-Path $srcDir 'modules/Engine/Import-EngineModules.ps1') . (Join-Path $srcDir 'modules/Engine/Import-EngineModules.ps1')
@ -39,7 +24,7 @@ Import-EngineModules -Engine Release
$releaseExtension = $null $releaseExtension = $null
if (Get-Command Initialize-ReleaseExtension -ErrorAction SilentlyContinue) { if (Get-Command Initialize-ReleaseExtension -ErrorAction SilentlyContinue) {
$releaseExtension = Initialize-ReleaseExtension -ScriptDir $PSScriptRoot -ArgumentList @($extensionArgs) $releaseExtension = Initialize-ReleaseExtension -ScriptDir $PSScriptRoot -ArgumentList $args
} }
$settings = if ($null -ne $releaseExtension) { $settings = if ($null -ne $releaseExtension) {
@ -50,29 +35,8 @@ else {
} }
$configuredPlugins = Get-ConfiguredPlugins -Settings $settings $configuredPlugins = Get-ConfiguredPlugins -Settings $settings
if ($installOnly) {
$enabledBefore = @($configuredPlugins | Where-Object { $_.enabled } | ForEach-Object { [string]$_.name })
$configuredPlugins = @(Select-HelmInstallPlugins -Plugins $configuredPlugins -ScriptDir $PSScriptRoot)
$kept = @($configuredPlugins | ForEach-Object { [string]$_.name })
$skipped = @($enabledBefore | Where-Object { $_ -notin $kept })
if ($skipped.Count -gt 0) {
Write-Log -Level 'INFO' -Message "Install-only: skipping $($skipped -join ', '). Chart and images are taken from OCI."
}
Write-Log -Level 'INFO' -Message "Install-only: running $($kept -join ', ')." $releaseBanner = if ($null -ne $releaseExtension) {
}
Initialize-RepoUtilsVaultSecrets -Settings $settings -Plugins $configuredPlugins
$releaseBanner = if ($installOnly) {
$modeLabel = 'ha'
if ($null -ne $releaseExtension -and $releaseExtension.ContextData.PSObject.Properties.Name -contains 'deployMode') {
$modeLabel = [string]$releaseExtension.ContextData.deployMode
}
"HELM INSTALL (deploy mode: $modeLabel; OCI chart, no build or push)"
}
elseif ($null -ne $releaseExtension) {
$releaseExtension.StepBanner $releaseExtension.StepBanner
} }
else { else {
@ -132,11 +96,7 @@ if (-not $releaseStageInitialized) {
Write-Log -Level 'OK' -Message '==================================================' Write-Log -Level 'OK' -Message '=================================================='
if ($releaseHadPluginFailures) { if ($releaseHadPluginFailures) {
$failureLabel = if ($installOnly) { 'HELM INSTALL FAILED' } else { 'RELEASE FAILED' } Write-Log -Level 'ERROR' -Message 'RELEASE FAILED'
Write-Log -Level 'ERROR' -Message $failureLabel
}
elseif ($installOnly) {
Write-Log -Level 'OK' -Message 'HELM INSTALL COMPLETE'
} }
elseif ($engineContext.PSObject.Properties.Name -contains 'skipPublishPlugins' -and $engineContext.skipPublishPlugins) { elseif ($engineContext.PSObject.Properties.Name -contains 'skipPublishPlugins' -and $engineContext.skipPublishPlugins) {
Write-Log -Level 'OK' -Message 'RUN COMPLETE (publish skipped by ReleasePublishGuard)' Write-Log -Level 'OK' -Message 'RUN COMPLETE (publish skipped by ReleasePublishGuard)'
@ -149,7 +109,7 @@ else {
} }
Write-Log -Level 'OK' -Message '==================================================' Write-Log -Level 'OK' -Message '=================================================='
if (-not $installOnly -and $engineContext.isNonReleaseBranch -and -not ($engineContext.PSObject.Properties.Name -contains 'skipPublishPlugins' -and $engineContext.skipPublishPlugins)) { if ($engineContext.isNonReleaseBranch -and -not ($engineContext.PSObject.Properties.Name -contains 'skipPublishPlugins' -and $engineContext.skipPublishPlugins)) {
$preferredReleaseBranch = Get-PreferredReleaseBranch -EngineContext $engineContext $preferredReleaseBranch = Get-PreferredReleaseBranch -EngineContext $engineContext
Write-Log -Level 'INFO' -Message "For publish, use an allowed branch (see ReleasePublishGuard.branches), e.g. '$preferredReleaseBranch', and satisfy the guard requirements." Write-Log -Level 'INFO' -Message "For publish, use an allowed branch (see ReleasePublishGuard.branches), e.g. '$preferredReleaseBranch', and satisfy the guard requirements."
} }

View File

@ -2,8 +2,6 @@
"$schema": "https://json-schema.org/draft-07/schema", "$schema": "https://json-schema.org/draft-07/schema",
"title": "Release Package Script Settings", "title": "Release Package Script Settings",
"description": "Invoke-ReleasePackage.ps1 plugin settings for maksit-persistence.", "description": "Invoke-ReleasePackage.ps1 plugin settings for maksit-persistence.",
"repoUtilsSecretsShared": "RepoUtilsSecretsShared",
"repoUtilsSecrets": "RepoUtilsSecrets",
"plugins": [ "plugins": [
{ {
"name": "DotNetReleaseVersion", "name": "DotNetReleaseVersion",
@ -63,13 +61,45 @@
"name": "GitHub", "name": "GitHub",
"stageLabel": "release", "stageLabel": "release",
"enabled": true, "enabled": true,
"githubSecret": "GitClone", "githubSecret": "GitHub",
"repository": "https://github.com/MAKS-IT-COM/maksit-persistence", "repository": "https://github.com/MAKS-IT-COM/maksit-persistence",
"releaseNotesFile": "..\\..\\..\\CHANGELOG.md", "releaseNotesFile": "..\\..\\..\\CHANGELOG.md",
"releaseTitlePattern": "Release {version}" "releaseTitlePattern": "Release {version}"
}, },
{ {
"name": "CleanupArtifacts", "name": "DotNetNuGet",
"stageLabel": "release",
"enabled": true,
"nugetSecret": "NuGet",
"source": "https://api.nuget.org/v3/index.json"
},
{
"name": "NpmReleaseVersion",
"stageLabel": "build",
"enabled": false,
"packageJsonPath": "..\\..\\..\\src\\package.json",
"syncWorkspaceVersions": true
},
{
"name": "NpmBuild",
"stageLabel": "build",
"enabled": false,
"workspaceRoot": "..\\..\\..\\src",
"useCi": true,
"buildScript": "build"
},
{
"name": "NpmPublish",
"stageLabel": "release",
"enabled": false,
"npmSecret": "Npm",
"registry": "https://registry.npmjs.org",
"access": "public",
"workspaceRoot": "..\\..\\..\\src",
"publishOrder": []
},
{
"name": "DotNetCleanupArtifacts",
"stageLabel": "release", "stageLabel": "release",
"enabled": true, "enabled": true,
"includePatterns": [ "includePatterns": [

View File

@ -6,65 +6,16 @@
Keep a Changelog header parsing and section extraction. Keep a Changelog header parsing and section extraction.
.DESCRIPTION .DESCRIPTION
Supports Keep a Changelog version lines and shared SemVer checks, including prerelease: Supports only the standard Keep a Changelog version line:
## [1.0.0] - 2026-05-24 ## [1.0.0] - 2026-05-24
## [0.1.0-alpha.1] - 2026-08-21
## [0.1.0-beta.1] - 2026-08-21
## [0.1.0-rc.1] - 2026-08-21
#> #>
function Get-ChangelogSemverPattern {
return '\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?'
}
function Test-ReleaseSemver {
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Version
)
if ([string]::IsNullOrWhiteSpace($Version)) {
return $false
}
return [bool]($Version -match ('^' + (Get-ChangelogSemverPattern) + '$'))
}
function Test-ReleaseSemverPrerelease {
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Version
)
return (Test-ReleaseSemver -Version $Version) -and ($Version -match '-')
}
function Get-ReleaseSemverPrereleaseLabel {
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Version
)
if (-not (Test-ReleaseSemverPrerelease -Version $Version)) {
return $null
}
if ($Version -match '^\d+\.\d+\.\d+-([A-Za-z][0-9A-Za-z]*)') {
return $Matches[1].ToLowerInvariant()
}
return 'next'
}
function Get-ChangelogVersionHeaderPattern { function Get-ChangelogVersionHeaderPattern {
return '(?m)^##\s+\[(' + (Get-ChangelogSemverPattern) + ')\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$' return '(?m)^##\s+\[(\d+\.\d+\.\d+)\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
} }
function Get-ChangelogNextVersionHeaderPattern { function Get-ChangelogNextVersionHeaderPattern {
return '(?m)^##\s+\[' + (Get-ChangelogSemverPattern) + '\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$' return '(?m)^##\s+\[\d+\.\d+\.\d+\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
} }
function Get-LatestChangelogVersion { function Get-LatestChangelogVersion {
@ -102,4 +53,4 @@ function Get-ChangelogReleaseNotesSection {
return $match.Value.Trim() return $match.Value.Trim()
} }
Export-ModuleMember -Function Get-ChangelogSemverPattern, Test-ReleaseSemver, Test-ReleaseSemverPrerelease, Get-ReleaseSemverPrereleaseLabel, Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection Export-ModuleMember -Function Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection

View File

@ -321,37 +321,6 @@ function Get-EngineState {
return $null return $null
} }
function Add-ReleaseAssetPaths {
param(
[Parameter(Mandatory = $true)]
[psobject]$Context,
[Parameter(Mandatory = $true)]
[AllowEmptyCollection()]
[string[]]$Path
)
$list = [System.Collections.Generic.List[string]]::new()
$existing = Get-EngineFact -Context $Context -Namespace 'release' -Name 'assetPaths' -Default @() -LegacyProperty @('releaseAssetPaths')
foreach ($item in @($existing)) {
$value = [string]$item
if (-not [string]::IsNullOrWhiteSpace($value) -and -not $list.Contains($value)) {
$list.Add($value)
}
}
foreach ($item in @($Path)) {
$value = [string]$item
if ([string]::IsNullOrWhiteSpace($value) -or $list.Contains($value)) {
continue
}
$list.Add($value)
}
Set-EngineFact -Context $Context -Namespace 'release' -Name 'assetPaths' -Value @($list) -Overwrite Replace -LegacyProperty 'releaseAssetPaths'
}
Export-ModuleMember -Function ` Export-ModuleMember -Function `
Resolve-RelativePaths, ` Resolve-RelativePaths, `
Initialize-EngineFactsBag, ` Initialize-EngineFactsBag, `
@ -360,5 +329,4 @@ Export-ModuleMember -Function `
Test-EngineFact, ` Test-EngineFact, `
Set-EngineState, ` Set-EngineState, `
Add-EnginePublishCompletion, ` Add-EnginePublishCompletion, `
Add-ReleaseAssetPaths, `
Get-EngineState Get-EngineState

View File

@ -15,7 +15,6 @@ function Import-EngineModules {
(Join-Path $modulesDir 'ScriptConfig.psm1'), (Join-Path $modulesDir 'ScriptConfig.psm1'),
(Join-Path $modulesDir 'Logging.psm1'), (Join-Path $modulesDir 'Logging.psm1'),
(Join-Path $engineModuleDir 'PluginSupport.psm1'), (Join-Path $engineModuleDir 'PluginSupport.psm1'),
(Join-Path $engineModuleDir 'VaultSupport.psm1'),
(Join-Path $engineModuleDir 'EngineContext.psm1') (Join-Path $engineModuleDir 'EngineContext.psm1')
) )

View File

@ -33,7 +33,6 @@ function Test-IsEngineRuntimeModuleName {
'TestRunner', 'TestRunner',
'EngineContext', 'EngineContext',
'PluginSupport', 'PluginSupport',
'VaultSupport',
'ReleaseSupport', 'ReleaseSupport',
'TestSupport' 'TestSupport'
), ),
@ -54,7 +53,7 @@ function Get-PluginDependencyGroupDirectories {
} }
# Prefer Shared (helpers), then stock host groups; any other plugins/{Group}/ is discovered. # Prefer Shared (helpers), then stock host groups; any other plugins/{Group}/ is discovered.
$preferred = @('Shared', 'Platform', 'DotNet', 'Npm', 'Desktop') $preferred = @('Shared', 'Platform', 'DotNet', 'Npm')
$dirs = [System.Collections.Generic.List[string]]::new() $dirs = [System.Collections.Generic.List[string]]::new()
foreach ($name in $preferred) { foreach ($name in $preferred) {
$path = Join-Path $PluginsRoot $name $path = Join-Path $PluginsRoot $name
@ -294,588 +293,131 @@ function Test-PluginMutatesRemote {
return $false return $false
} }
function Get-RepoUtilsEnvironmentVariable { function Get-SecretEnvironmentValue {
<# <#
.SYNOPSIS .SYNOPSIS
Reads a named environment variable from Process, then Windows User, then Machine. Reads a secret value from an environment variable by logical name.
.DESCRIPTION .DESCRIPTION
Process wins (CICD sandbox inject, `$env:Name`, explicit session values). When the Plugins never store secret material in scriptSettings.json. Settings hold a
current process was started before a User-level pack was set, User/Machine still logical name (e.g. "GitHub", "NuGet"); the process environment variable with
apply so laptop releases do not require a new shell. An explicit process value — that same name must be set before the engine runs.
including empty JSON `{}` — shadows User/Machine.
.PARAMETER Name
Logical secret name — also the environment variable name to read.
.OUTPUTS
System.String. The environment variable value, or $null when unset.
.EXAMPLE
$token = Get-SecretEnvironmentValue -Name 'GitHub'
#> #>
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$Name [string]$Name
) )
if ([string]::IsNullOrWhiteSpace($Name)) { return [Environment]::GetEnvironmentVariable($Name)
throw "Environment variable name is required."
}
$processValue = [Environment]::GetEnvironmentVariable($Name, 'Process')
# Empty string is what SetEnvironmentVariable($null, Process) leaves behind;
# treat it as unset so Windows User packs still apply. Explicit '{}' shadows User.
if (-not [string]::IsNullOrWhiteSpace($processValue)) {
return $processValue
}
foreach ($target in @('User', 'Machine')) {
try {
$value = [Environment]::GetEnvironmentVariable($Name, $target)
}
catch {
continue
}
if (-not [string]::IsNullOrWhiteSpace($value)) {
return $value
}
}
return $null
}
function Get-RepoUtilsSecretsEnvNames {
<#
.SYNOPSIS
Reads declared pack environment variable names from scriptSettings / engine context.
#>
param(
[Parameter(Mandatory = $false)]
$Settings
)
$sharedName = $null
$packName = $null
if ($null -ne $Settings) {
if ($Settings.PSObject.Properties.Name -contains 'repoUtilsSecretsShared') {
$sharedName = [string]$Settings.repoUtilsSecretsShared
}
if ($Settings.PSObject.Properties.Name -contains 'repoUtilsSecrets') {
$packName = [string]$Settings.repoUtilsSecrets
}
}
$sharedName = if ($null -eq $sharedName) { '' } else { $sharedName.Trim() }
$packName = if ($null -eq $packName) { '' } else { $packName.Trim() }
if ([string]::IsNullOrWhiteSpace($sharedName) -or [string]::IsNullOrWhiteSpace($packName)) {
throw "scriptSettings.json must declare repoUtilsSecretsShared and repoUtilsSecrets (environment variable names, e.g. RepoUtilsSecretsShared / RepoUtilsSecrets)."
}
return [pscustomobject]@{
SharedEnv = $sharedName
PackEnv = $packName
}
}
function ConvertFrom-RepoUtilsSecretsPackJson {
<#
.SYNOPSIS
Parses a RepoUtilsSecrets JSON object. Empty input is {}. Bare non-JSON throws.
#>
param(
[Parameter(Mandatory = $false)]
[AllowEmptyString()]
[string]$Raw,
[Parameter(Mandatory = $true)]
[string]$SourceName
)
if ([string]::IsNullOrWhiteSpace($Raw)) {
return [pscustomobject]@{}
}
$trimmed = $Raw.Trim()
if (-not $trimmed.StartsWith('{')) {
throw "${SourceName} must be a JSON object (RepoUtilsSecrets pack), not a bare string."
}
try {
$parsed = $trimmed | ConvertFrom-Json -ErrorAction Stop
}
catch {
throw "${SourceName} is not valid JSON: $($_.Exception.Message)"
}
if ($null -eq $parsed -or $parsed -is [System.Collections.IEnumerable]) {
throw "${SourceName} JSON must be an object."
}
return $parsed
}
function ConvertTo-OrdinalPropertyMap {
param($Object)
$map = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal)
if ($null -eq $Object) {
return $map
}
if ($Object -is [System.Collections.IDictionary]) {
foreach ($key in @($Object.Keys)) {
$name = [string]$key
if ([string]::IsNullOrWhiteSpace($name)) {
continue
}
$map[$name] = $Object[$key]
}
return $map
}
foreach ($property in $Object.PSObject.Properties) {
if ($property.MemberType -notin @('NoteProperty', 'Property')) {
continue
}
$map[[string]$property.Name] = $property.Value
}
return $map
}
function ConvertFrom-OrdinalPropertyMap {
param(
[Parameter(Mandatory = $true)]
[System.Collections.Generic.Dictionary[string, object]]$Map
)
$properties = [ordered]@{}
foreach ($key in $Map.Keys) {
$properties[$key] = $Map[$key]
}
return [pscustomobject]$properties
}
function Merge-RepoUtilsSecretsPackObjects {
<#
.SYNOPSIS
Appsettings-style merge: org-pack first, slug overlay. Nested merge for any object-valued slot (typically ContainerRegistry).
#>
param(
$Base,
$Overlay
)
$result = ConvertTo-OrdinalPropertyMap -Object $Base
$overlayMap = ConvertTo-OrdinalPropertyMap -Object $Overlay
foreach ($key in @($overlayMap.Keys)) {
if (-not (Test-ContainerRegistryCatalogKey -Key $key)) {
throw "RepoUtilsSecrets pack key '$key' must be PascalCase (e.g. GitClone, ContainerRegistry)."
}
$overlayValue = $overlayMap[$key]
$overlayIsObject = ($null -ne $overlayValue) -and -not ($overlayValue -is [string]) -and -not ($overlayValue -is [ValueType]) -and -not ($overlayValue -is [System.Collections.IEnumerable])
if ($overlayIsObject) {
$baseCatalog = $null
if ($result.ContainsKey($key)) {
$baseCatalog = $result[$key]
}
$mergedCatalog = ConvertTo-OrdinalPropertyMap -Object $baseCatalog
$overlayCatalogMap = ConvertTo-OrdinalPropertyMap -Object $overlayValue
foreach ($catalogKey in @($overlayCatalogMap.Keys)) {
if (-not (Test-ContainerRegistryCatalogKey -Key $catalogKey)) {
throw "Catalog key '$catalogKey' in pack slot '$key' must be PascalCase (e.g. Harbor, InCluster)."
}
$mergedCatalog[$catalogKey] = $overlayCatalogMap[$catalogKey]
}
$result[$key] = ConvertFrom-OrdinalPropertyMap -Map $mergedCatalog
continue
}
$result[$key] = $overlayValue
}
foreach ($key in @($result.Keys)) {
if (-not (Test-ContainerRegistryCatalogKey -Key $key)) {
throw "RepoUtilsSecrets pack key '$key' must be PascalCase (e.g. GitClone, ContainerRegistry)."
}
}
return ConvertFrom-OrdinalPropertyMap -Map $result
}
function Get-MergedRepoUtilsSecretsPack {
<#
.SYNOPSIS
Merges $env:RepoUtilsSecretsShared then $env:RepoUtilsSecrets (names from settings).
#>
param(
[Parameter(Mandatory = $false)]
$Settings
)
$names = Get-RepoUtilsSecretsEnvNames -Settings $Settings
$sharedRaw = Get-RepoUtilsEnvironmentVariable -Name $names.SharedEnv
$packRaw = Get-RepoUtilsEnvironmentVariable -Name $names.PackEnv
$sharedObject = ConvertFrom-RepoUtilsSecretsPackJson -Raw $sharedRaw -SourceName $names.SharedEnv
$packObject = ConvertFrom-RepoUtilsSecretsPackJson -Raw $packRaw -SourceName $names.PackEnv
return Merge-RepoUtilsSecretsPackObjects -Base $sharedObject -Overlay $packObject
}
function Get-RepoUtilsSecretSlot {
<#
.SYNOPSIS
Reads a scalar pack slot (GitClone, NuGet, Npm, CosignKey, …) after merge.
#>
param(
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $false)]
$Settings,
[switch]$AllowMissing
)
if ([string]::IsNullOrWhiteSpace($Name) -or -not (Test-ContainerRegistryCatalogKey -Key $Name)) {
throw "RepoUtilsSecrets slot '$Name' must be PascalCase (e.g. GitClone, NuGet)."
}
$merged = Get-MergedRepoUtilsSecretsPack -Settings $Settings
$match = $null
foreach ($property in $merged.PSObject.Properties) {
if ($property.MemberType -notin @('NoteProperty', 'Property')) {
continue
}
if ([string]::Equals([string]$property.Name, $Name, [System.StringComparison]::Ordinal)) {
$match = $property
break
}
}
if ($null -eq $match) {
if ($AllowMissing) {
return $null
}
throw "RepoUtilsSecrets slot '$Name' is missing after merging org-pack and slug-pack."
}
$value = $match.Value
if ($value -is [string] -or $null -eq $value -or $value -is [ValueType]) {
$text = if ($null -eq $value) { '' } else { [string]$value }
if ([string]::IsNullOrWhiteSpace($text) -and -not $AllowMissing) {
throw "RepoUtilsSecrets slot '$Name' is empty."
}
if ([string]::IsNullOrWhiteSpace($text)) {
return $null
}
return $text
}
throw "RepoUtilsSecrets slot '$Name' must be a string (nested maps are only allowed for ContainerRegistry)."
}
function Copy-RepoUtilsSecretsEnvNamesToContext {
param(
[Parameter(Mandatory = $true)]
$Context,
[Parameter(Mandatory = $false)]
$Settings
)
if ($null -eq $Settings) {
return $Context
}
foreach ($name in @('repoUtilsSecretsShared', 'repoUtilsSecrets', 'vaultRepoUtilsApplication')) {
if ($Settings.PSObject.Properties.Name -contains $name -and -not [string]::IsNullOrWhiteSpace([string]$Settings.$name)) {
$Context | Add-Member -NotePropertyName $name -NotePropertyValue ([string]$Settings.$name).Trim() -Force
}
}
return $Context
}
function Test-ContainerRegistryCatalogKey {
<#
.SYNOPSIS
True when Key is PascalCase (Harbor, InCluster), matching env-slot names.
#>
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Key
)
return $Key -cmatch '^[A-Z][A-Za-z0-9]*$'
} }
function Resolve-PluginSecretName { function Resolve-PluginSecretName {
<# <#
.SYNOPSIS .SYNOPSIS
Reads a plugin setting that names a RepoUtilsSecrets pack subkey. Resolves a logical secret name from a plugin's scriptSettings entry.
.DESCRIPTION .DESCRIPTION
Settings such as githubSecret / nugetSecret / npmSecret / containerRegistrySecret / Reads a string property such as githubSecret / nugetSecret / npmSecret /
cosignKeySecret hold the PascalCase pack slot to read (e.g. GitClone, NuGet). containerRegistrySecret from the plugin settings object. Returns $null when
They are not environment variable names. the property is missing or blank.
.PARAMETER PluginSettings .PARAMETER PluginSettings
Plugin settings object from scriptSettings.json. Plugin settings object from scriptSettings.json (the enabled plugin entry).
.PARAMETER PropertyName .PARAMETER PropertyName
Settings property that holds the pack subkey name (e.g. 'githubSecret'). Settings property that holds the logical secret name (e.g. 'githubSecret').
.PARAMETER PluginDisplayName .OUTPUTS
Plugin name used in required/validation errors. System.String. Trimmed logical secret name, or $null.
.PARAMETER Required .EXAMPLE
Throw when the property is missing or blank. $name = Resolve-PluginSecretName -PluginSettings $plugin -PropertyName 'nugetSecret'
$key = Get-SecretEnvironmentValue -Name $name
#> #>
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
$PluginSettings, $PluginSettings,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$PropertyName, [string]$PropertyName
[Parameter(Mandatory = $false)]
[string]$PluginDisplayName,
[switch]$Required
) )
$display = if ([string]::IsNullOrWhiteSpace($PluginDisplayName)) { 'Plugin' } else { $PluginDisplayName } if ($PluginSettings.PSObject.Properties.Name -contains $PropertyName) {
$value = $null $value = [string]$PluginSettings.$PropertyName
if ($null -ne $PluginSettings -and $PluginSettings.PSObject.Properties.Name -contains $PropertyName) { if (-not [string]::IsNullOrWhiteSpace($value)) {
$raw = [string]$PluginSettings.$PropertyName return $value.Trim()
if (-not [string]::IsNullOrWhiteSpace($raw)) {
$value = $raw.Trim()
} }
} }
if ([string]::IsNullOrWhiteSpace($value)) {
if ($Required) {
throw "$display requires '$PropertyName' (PascalCase pack subkey, e.g. GitClone, NuGet, ContainerRegistry)."
}
return $null return $null
} }
if (-not (Test-ContainerRegistryCatalogKey -Key $value)) { function Get-RegistryCredentialsFromRuntime {
throw "$display '$PropertyName' '$value' must be PascalCase (e.g. GitClone, NuGet, ContainerRegistry)."
}
return $value
}
function Assert-RetiredContainerRegistrySettingsAbsent {
<# <#
.SYNOPSIS .SYNOPSIS
Throws when obsolete per-registry secret settings are present. Loads container-registry username/password from a logical secret name.
.DESCRIPTION
Looks up the environment variable named by SecretName. The value must be
Base64(UTF8('username:password')). Used by registry login and image-pull
secret creation — never pass the password itself as a parameter.
.PARAMETER SecretName
Logical secret name (environment variable name), not a password or token.
.PARAMETER SharedSettings
Optional engine shared context (reserved for callers that thread context).
.OUTPUTS
Hashtable with User and Password keys (decoded credential material).
.EXAMPLE
$creds = Get-RegistryCredentialsFromRuntime -SecretName 'ContainerRegistry'
# $creds.User / $creds.Password
#> #>
param( # SecretName is a logical env-var name from scriptSettings, not a password value.
$Object, [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
[Parameter(Mandatory = $true)] 'PSAvoidUsingPlainTextForPassword',
[string]$Context 'SecretName',
) Justification = 'Logical secret name for env lookup (Base64 username:password); not a credential value.'
)]
if ($null -eq $Object) {
return
}
$retired = @(
'imagesCredentialsSecret',
'additionalImageRegistries',
'additionalImageRegistryUrls',
'additionalImagesCredentialsSecret',
'helmOciCredentialsSecret'
)
foreach ($name in $retired) {
$present = $false
if ($Object -is [System.Collections.IDictionary]) {
$present = $Object.Contains($name)
}
elseif ($Object.PSObject.Properties.Name -contains $name) {
$present = $true
}
if ($present) {
throw "${Context}: '$name' is not supported. Use the ContainerRegistry JSON catalog with PascalCase containerRegistryKey / helmRegistryKey."
}
}
}
function ConvertFrom-RegistryCredentialPayload {
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$Payload, [string]$SecretName,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $false)]
[string]$ContextName [psobject]$SharedSettings
) )
$raw = Get-SecretEnvironmentValue -Name $SecretName
if ([string]::IsNullOrWhiteSpace($raw)) {
throw "Environment variable '$SecretName' is not set."
}
try { try {
$decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Payload.Trim())) $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($raw))
} }
catch { catch {
throw "Failed to decode '$ContextName' as Base64 (expected base64('username:password')): $($_.Exception.Message)" throw "Failed to decode '$SecretName' as Base64 (expected base64('username:password')): $($_.Exception.Message)"
} }
$parts = $decoded -split ':', 2 $parts = $decoded -split ':', 2
if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) { if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) {
throw "Decoded '$ContextName' must be in the form 'username:password'." throw "Decoded '$SecretName' must be in the form 'username:password'."
} }
return @{ User = $parts[0]; Password = $parts[1] } return @{ User = $parts[0]; Password = $parts[1] }
} }
function Get-ContainerRegistryCatalogObject {
<#
.SYNOPSIS
Validates a PascalCase JSON map of Base64(username:password) values.
#>
param(
[Parameter(Mandatory = $true)]
$Catalog,
[Parameter(Mandatory = $false)]
[string]$SourceName = 'ContainerRegistry'
)
if ($Catalog -is [string]) {
$raw = [string]$Catalog
$trimmed = $raw.Trim()
if (-not $trimmed.StartsWith('{')) {
throw "$SourceName must be a JSON catalog object { `"Harbor`": `"<Base64>`", `"InCluster`": `"<Base64>`" }."
}
try {
$Catalog = $trimmed | ConvertFrom-Json -ErrorAction Stop
}
catch {
throw "$SourceName is not valid JSON: $($_.Exception.Message)"
}
}
if ($null -eq $Catalog -or $Catalog -is [string] -or $Catalog -is [ValueType] -or $Catalog -is [System.Collections.IEnumerable]) {
throw "$SourceName JSON catalog must be an object of PascalCase keys to Base64(username:password)."
}
$keyCount = 0
foreach ($property in $Catalog.PSObject.Properties) {
if ($property.MemberType -notin @('NoteProperty', 'Property')) {
continue
}
$keyCount++
$keyName = [string]$property.Name
if (-not (Test-ContainerRegistryCatalogKey -Key $keyName)) {
throw "Catalog key '$keyName' in '$SourceName' must be PascalCase (e.g. Harbor, InCluster)."
}
}
if ($keyCount -eq 0) {
throw "$SourceName JSON catalog has no PascalCase keys."
}
return $Catalog
}
function Get-RegistryCredentialsFromRuntime {
<#
.SYNOPSIS
Loads container-registry username/password from the merged RepoUtilsSecrets pack.
.DESCRIPTION
Reads a nested catalog slot (named by -Slot, typically ContainerRegistry) after
org-pack + slug-pack merge. -Key (PascalCase, ordinal match) selects an entry.
.PARAMETER Key
PascalCase catalog key (e.g. Harbor). Required.
.PARAMETER Slot
PascalCase pack subkey that holds the catalog object (from containerRegistrySecret).
.PARAMETER SharedSettings
Engine shared context (must declare repoUtilsSecretsShared / repoUtilsSecrets).
.OUTPUTS
Hashtable with User and Password keys (decoded credential material).
#>
param(
# Omitted arguments must throw. Mandatory prompts on an interactive host and stalls tests and release runs.
[AllowEmptyString()]
[string]$Key,
[AllowEmptyString()]
[string]$Slot,
[psobject]$SharedSettings
)
if ([string]::IsNullOrWhiteSpace($Slot) -or -not (Test-ContainerRegistryCatalogKey -Key $Slot)) {
throw "containerRegistrySecret '$Slot' must be PascalCase (e.g. ContainerRegistry)."
}
if ([string]::IsNullOrWhiteSpace($Key)) {
throw "Pass -Key (PascalCase, e.g. Harbor) to select an entry in pack slot '$Slot'."
}
if (-not (Test-ContainerRegistryCatalogKey -Key $Key)) {
throw "containerRegistryKey '$Key' must be PascalCase (e.g. Harbor, InCluster)."
}
$merged = Get-MergedRepoUtilsSecretsPack -Settings $SharedSettings
$catalogProperty = $null
foreach ($property in $merged.PSObject.Properties) {
if ($property.MemberType -notin @('NoteProperty', 'Property')) {
continue
}
if ([string]::Equals([string]$property.Name, $Slot, [System.StringComparison]::Ordinal)) {
$catalogProperty = $property
break
}
}
if ($null -eq $catalogProperty -or $null -eq $catalogProperty.Value) {
throw "RepoUtilsSecrets slot '$Slot' is missing after merging org-pack and slug-pack."
}
$catalog = Get-ContainerRegistryCatalogObject -Catalog $catalogProperty.Value -SourceName $Slot
$match = $null
foreach ($property in $catalog.PSObject.Properties) {
if ($property.MemberType -notin @('NoteProperty', 'Property')) {
continue
}
if ([string]::Equals([string]$property.Name, $Key, [System.StringComparison]::Ordinal)) {
$match = $property
break
}
}
if ($null -eq $match) {
throw "Catalog key '$Key' was not found in '$Slot' (ordinal PascalCase match)."
}
$payload = [string]$match.Value
if ([string]::IsNullOrWhiteSpace($payload)) {
throw "Catalog key '$Key' in '$Slot' is empty."
}
return ConvertFrom-RegistryCredentialPayload -Payload $payload -ContextName "$Slot.$Key"
}
function Resolve-EngineDirectoryFromSharedSettings { function Resolve-EngineDirectoryFromSharedSettings {
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
@ -1054,7 +596,7 @@ function Resolve-PluginModulePath {
$candidatePaths = [System.Collections.Generic.List[string]]::new() $candidatePaths = [System.Collections.Generic.List[string]]::new()
$candidatePaths.Add((Join-Path (Join-Path $EngineDirectory "custom") $pluginFileName)) $candidatePaths.Add((Join-Path (Join-Path $EngineDirectory "custom") $pluginFileName))
$preferredGroups = @('Platform', 'DotNet', 'Npm', 'Desktop') $preferredGroups = @('Platform', 'DotNet', 'Npm')
$candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $preferredGroups[0]) $pluginFileName)) $candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $preferredGroups[0]) $pluginFileName))
if (Get-Command Get-ExtensionPluginModulePaths -ErrorAction SilentlyContinue) { if (Get-Command Get-ExtensionPluginModulePaths -ErrorAction SilentlyContinue) {
@ -1214,4 +756,4 @@ function Invoke-ConfiguredPlugin {
} }
} }
Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Get-PluginMetadataObject, Test-PluginCompatible, Test-PluginMutatesRemote, Get-RepoUtilsEnvironmentVariable, Get-RepoUtilsSecretsEnvNames, ConvertFrom-RepoUtilsSecretsPackJson, Merge-RepoUtilsSecretsPackObjects, Get-MergedRepoUtilsSecretsPack, Get-RepoUtilsSecretSlot, Copy-RepoUtilsSecretsEnvNamesToContext, Resolve-PluginSecretName, Test-ContainerRegistryCatalogKey, Assert-RetiredContainerRegistrySettingsAbsent, Get-ContainerRegistryCatalogObject, Get-RegistryCredentialsFromRuntime, Test-PluginSkipsRemoteMutation, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Get-PluginMetadataObject, Test-PluginCompatible, Test-PluginMutatesRemote, Resolve-PluginSecretName, Get-SecretEnvironmentValue, Get-RegistryCredentialsFromRuntime, Test-PluginSkipsRemoteMutation, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin

View File

@ -213,7 +213,6 @@ function New-EngineContext {
skipPublishPlugins = $false skipPublishPlugins = $false
facts = [ordered]@{} facts = [ordered]@{}
} }
$context = Copy-RepoUtilsSecretsEnvNamesToContext -Context $context -Settings $Settings
$versionSource = Resolve-EngineContextVersion -Plugins $Plugins -Context $context -ScriptDir $ScriptDir $versionSource = Resolve-EngineContextVersion -Plugins $Plugins -Context $context -ScriptDir $ScriptDir
$version = [string](Get-EngineState -Context $context -Name 'version' -Required) $version = [string](Get-EngineState -Context $context -Name 'version' -Required)
@ -266,61 +265,4 @@ function Get-PreferredReleaseBranch {
return "main" return "main"
} }
function Select-HelmInstallPlugins { Export-ModuleMember -Function Assert-WorkingTreeClean, Initialize-ReleaseStageContext, New-EngineContext, Get-PreferredReleaseBranch
<#
.SYNOPSIS
Keeps the release version plugin and HelmSelfDeploy for an OCI install without build or push.
.DESCRIPTION
Install-Helm-*.bat passes -InstallOnly. The chart and images are already in OCI, so compile,
test, image build, image push, and HelmPush are not run. The version plugin still supplies
the chart tag (shared.version). HelmSelfDeploy dryRun is rejected: that path needs the local
package HelmPush keeps in the same run.
#>
param(
[Parameter(Mandatory = $true)]
[AllowEmptyCollection()]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$ScriptDir
)
$versionPlugins = @(Get-EnabledVersionPlugins -Plugins $Plugins -ScriptDir $ScriptDir)
if ($versionPlugins.Count -eq 0) {
throw "Install-only Helm requires exactly one enabled release version plugin (providesVersion) so the OCI chart tag can be resolved. Build and push plugins are not run."
}
if ($versionPlugins.Count -gt 1) {
$names = ($versionPlugins | ForEach-Object { [string]$_.name }) -join ', '
throw "Install-only Helm requires exactly one enabled release version plugin. Found: $names."
}
$deployPlugins = @($Plugins | Where-Object {
$null -ne $_ -and
$_.enabled -and
[string]$_.name -eq 'HelmSelfDeploy'
})
if ($deployPlugins.Count -eq 0) {
throw "Install-only Helm requires an enabled HelmSelfDeploy plugin. The chart is installed from OCI; image build, image push, and HelmPush are not run."
}
if ($deployPlugins.Count -gt 1) {
throw "Install-only Helm found multiple enabled HelmSelfDeploy plugins."
}
$deployPlugin = $deployPlugins[0]
if ($deployPlugin.PSObject.Properties.Name -contains 'dryRun' -and $null -ne $deployPlugin.dryRun -and [bool]$deployPlugin.dryRun) {
throw "Install-only Helm cannot use HelmSelfDeploy dryRun. That path needs the local package from HelmPush in the same run. Omit dryRun to install the chart already published to OCI."
}
$versionName = [string]$versionPlugins[0].name
return @($Plugins | Where-Object {
$null -ne $_ -and
$_.enabled -and
([string]$_.name -eq $versionName -or [string]$_.name -eq 'HelmSelfDeploy')
})
}
Export-ModuleMember -Function Assert-WorkingTreeClean, Initialize-ReleaseStageContext, New-EngineContext, Get-PreferredReleaseBranch, Select-HelmInstallPlugins

View File

@ -38,7 +38,6 @@ function New-EngineContext {
utilsDir = $SrcDir utilsDir = $SrcDir
facts = [ordered]@{} facts = [ordered]@{}
} }
$context = Copy-RepoUtilsSecretsEnvNamesToContext -Context $context -Settings $Settings
$expandContext = Get-Command Expand-ExtensionEngineContext -ErrorAction SilentlyContinue $expandContext = Get-Command Expand-ExtensionEngineContext -ErrorAction SilentlyContinue
if ($expandContext) { if ($expandContext) {

View File

@ -1,373 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
function Test-RepoUtilsUseVaultEnabled {
param(
[Parameter(Mandatory = $true)]
$Settings
)
if ($null -eq $Settings -or -not ($Settings.PSObject.Properties.Name -contains 'useVault')) {
return $false
}
$value = $Settings.useVault
if ($value -is [bool]) {
return $value
}
return [string]$value -eq 'true'
}
function ConvertFrom-VaultConnectionSecret {
param(
[Parameter(Mandatory = $true)]
[string]$Raw
)
$trimmed = $Raw.Trim()
$separator = $trimmed.IndexOf('|')
if ($separator -lt 1 -or $separator -ge ($trimmed.Length - 1)) {
throw "Vault connection must be 'baseAddress|apiKey' (pipe separator). Example: http://172.16.0.14|your-key"
}
$baseAddress = $trimmed.Substring(0, $separator).Trim().TrimEnd('/')
$apiKey = $trimmed.Substring($separator + 1).Trim()
if ([string]::IsNullOrWhiteSpace($baseAddress) -or [string]::IsNullOrWhiteSpace($apiKey)) {
throw "Vault connection is missing base address or API key."
}
return [pscustomobject]@{
BaseAddress = $baseAddress
ApiKey = $apiKey
}
}
function Get-RepoUtilsVaultScope {
param(
[Parameter(Mandatory = $true)]
$Settings
)
$organization = [Environment]::GetEnvironmentVariable('MAKSIT_VAULT_ORGANIZATION')
if ([string]::IsNullOrWhiteSpace($organization) -and ($Settings.PSObject.Properties.Name -contains 'vaultOrganization')) {
$organization = [string]$Settings.vaultOrganization
}
$application = [Environment]::GetEnvironmentVariable('MAKSIT_VAULT_APPLICATION')
if ([string]::IsNullOrWhiteSpace($application) -and ($Settings.PSObject.Properties.Name -contains 'vaultApplication')) {
$application = [string]$Settings.vaultApplication
}
$organization = if ($null -eq $organization) { '' } else { $organization.Trim() }
$application = if ($null -eq $application) { '' } else { $application.Trim() }
if ([string]::IsNullOrWhiteSpace($organization) -or [string]::IsNullOrWhiteSpace($application)) {
throw "useVault is true but vault organization/application are not set. Set vaultOrganization/vaultApplication in scriptSettings.json or MAKSIT_VAULT_ORGANIZATION / MAKSIT_VAULT_APPLICATION."
}
return [pscustomobject]@{
Organization = $organization
Application = $application
}
}
function Get-RepoUtilsVaultConnectionSecretName {
param(
[Parameter(Mandatory = $true)]
$Settings
)
if (($Settings.PSObject.Properties.Name -contains 'vaultConnectionSecret') -and
-not [string]::IsNullOrWhiteSpace([string]$Settings.vaultConnectionSecret)) {
return ([string]$Settings.vaultConnectionSecret).Trim()
}
return 'MAKSIT_VAULT'
}
function Get-RepoUtilsVaultOrgPackApplicationName {
param(
[Parameter(Mandatory = $true)]
$Settings
)
$application = $null
if ($Settings.PSObject.Properties.Name -contains 'vaultRepoUtilsApplication') {
$application = [string]$Settings.vaultRepoUtilsApplication
}
$application = if ($null -eq $application) { '' } else { $application.Trim() }
if ([string]::IsNullOrWhiteSpace($application)) {
throw "useVault is true but vaultRepoUtilsApplication is not set in scriptSettings.json (Vault application for the org-pack, e.g. Shared)."
}
return $application
}
function Get-VaultNameFilterExpression {
param(
[Parameter(Mandatory = $true)]
[string]$Name
)
$escaped = $Name.Replace('\', '\\').Replace('"', '\"')
return "Name == `"$escaped`""
}
function Import-RepoUtilsVaultClientModule {
$modulePath = [Environment]::GetEnvironmentVariable('MAKSIT_VAULT_MODULE_PATH')
if (-not [string]::IsNullOrWhiteSpace($modulePath)) {
Import-Module $modulePath -Force -ErrorAction Stop
return
}
if (Get-Command Connect-Vault -ErrorAction SilentlyContinue) {
return
}
Import-Module 'MaksIT.Vault.Client.PowerShell' -ErrorAction SilentlyContinue
}
function Find-RepoUtilsVaultSecretMatch {
param(
[Parameter(Mandatory = $true)]
[string]$OrganizationName,
[Parameter(Mandatory = $true)]
[string]$ApplicationName,
[Parameter(Mandatory = $true)]
[string]$SecretName,
[Parameter(Mandatory = $true)]
[pscustomobject]$Connection,
[Parameter(Mandatory = $true)]
[ValidateSet('Cmdlet', 'Rest')]
[string]$Mode
)
$orgFilter = Get-VaultNameFilterExpression -Name $OrganizationName
$appFilter = Get-VaultNameFilterExpression -Name $ApplicationName
$pageNumber = 1
while ($true) {
$items = @()
$hasNext = $false
if ($Mode -eq 'Cmdlet') {
$response = Search-VaultSecrets -PageNumber $pageNumber -PageSize 100 `
-OrganizationFilters $orgFilter -ApplicationFilters $appFilter
if ($null -ne $response -and $null -ne $response.Items) {
$items = @($response.Items)
}
if ($null -ne $response) {
$hasNext = [bool]$response.HasNextPage
}
}
else {
$body = @{
pageNumber = $pageNumber
pageSize = 100
organizationFilters = $orgFilter
applicationFilters = $appFilter
} | ConvertTo-Json -Compress
$uri = "$($Connection.BaseAddress)/api/vault/secrets"
$headers = @{ 'X-API-KEY' = $Connection.ApiKey }
$response = Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -ContentType 'application/json' -Body $body
if ($null -ne $response.items) {
$items = @($response.items)
}
elseif ($null -ne $response.Items) {
$items = @($response.Items)
}
$hasNext = [bool]($response.hasNextPage)
if (-not $hasNext) {
$hasNext = [bool]($response.HasNextPage)
}
}
foreach ($item in $items) {
$itemName = [string]$(if ($item.PSObject.Properties.Name -contains 'Name') { $item.Name } else { $item.name })
if ([string]::Equals($itemName, $SecretName, [System.StringComparison]::Ordinal)) {
return $item
}
}
if (-not $hasNext) {
break
}
$pageNumber++
}
return $null
}
function Get-RepoUtilsVaultSecretValue {
param(
[Parameter(Mandatory = $true)]
$Match,
[Parameter(Mandatory = $true)]
[pscustomobject]$Connection,
[Parameter(Mandatory = $true)]
[ValidateSet('Cmdlet', 'Rest')]
[string]$Mode
)
$organizationId = if ($Match.PSObject.Properties.Name -contains 'OrganizationId') { $Match.OrganizationId } else { $Match.organizationId }
$applicationId = if ($Match.PSObject.Properties.Name -contains 'ApplicationId') { $Match.ApplicationId } else { $Match.applicationId }
$secretId = if ($Match.PSObject.Properties.Name -contains 'Id') { $Match.Id } else { $Match.id }
if ($Mode -eq 'Cmdlet') {
$version = Get-VaultSecret -OrganizationId $organizationId -ApplicationId $applicationId -SecretId $secretId -SecretVersion 'current'
if ($null -eq $version -or [string]::IsNullOrWhiteSpace([string]$version.Value)) {
return $null
}
return [string]$version.Value
}
$uri = "$($Connection.BaseAddress)/api/vault/organization/$organizationId/application/$applicationId/secret/$secretId" +
'?secretVersion=current'
$headers = @{ 'X-API-KEY' = $Connection.ApiKey }
$version = Invoke-RestMethod -Method Get -Uri $uri -Headers $headers
$value = if ($version.PSObject.Properties.Name -contains 'Value') { $version.Value } else { $version.value }
if ([string]::IsNullOrWhiteSpace([string]$value)) {
return $null
}
return [string]$value
}
function Resolve-RepoUtilsVaultSecretValue {
param(
[Parameter(Mandatory = $true)]
[string]$OrganizationName,
[Parameter(Mandatory = $true)]
[string]$ApplicationName,
[Parameter(Mandatory = $true)]
[string]$SecretName,
[Parameter(Mandatory = $true)]
[pscustomobject]$Connection,
[Parameter(Mandatory = $true)]
[string]$Mode
)
$match = Find-RepoUtilsVaultSecretMatch `
-OrganizationName $OrganizationName `
-ApplicationName $ApplicationName `
-SecretName $SecretName `
-Connection $Connection `
-Mode $Mode
if ($null -eq $match) {
return $null
}
$value = Get-RepoUtilsVaultSecretValue -Match $match -Connection $Connection -Mode $Mode
if ([string]::IsNullOrWhiteSpace($value)) {
return $null
}
Write-Log -Level 'INFO' -Message "Resolved Vault secret '$SecretName' from $OrganizationName/$ApplicationName"
return $value
}
function Initialize-RepoUtilsVaultSecrets {
param(
[Parameter(Mandatory = $true)]
$Settings,
[Parameter(Mandatory = $true)]
$Plugins
)
if (-not (Test-RepoUtilsUseVaultEnabled -Settings $Settings)) {
return
}
$names = Get-RepoUtilsSecretsEnvNames -Settings $Settings
$sharedRaw = Get-RepoUtilsEnvironmentVariable -Name $names.SharedEnv
$packRaw = Get-RepoUtilsEnvironmentVariable -Name $names.PackEnv
$sharedPresent = -not [string]::IsNullOrWhiteSpace($sharedRaw)
$packPresent = -not [string]::IsNullOrWhiteSpace($packRaw)
if ($sharedPresent -and $packPresent) {
[void](ConvertFrom-RepoUtilsSecretsPackJson -Raw $sharedRaw -SourceName $names.SharedEnv)
[void](ConvertFrom-RepoUtilsSecretsPackJson -Raw $packRaw -SourceName $names.PackEnv)
Write-Log -Level 'INFO' -Message "Vault mode: pack env vars '$($names.SharedEnv)' and '$($names.PackEnv)' already set; skipping Vault fetch."
return
}
$connectionName = Get-RepoUtilsVaultConnectionSecretName -Settings $Settings
$raw = [Environment]::GetEnvironmentVariable($connectionName)
if ([string]::IsNullOrWhiteSpace($raw)) {
throw "useVault is true but environment variable '$connectionName' is not set (expected baseAddress|apiKey)."
}
$connection = ConvertFrom-VaultConnectionSecret -Raw $raw
$scope = Get-RepoUtilsVaultScope -Settings $Settings
$orgPackApplication = Get-RepoUtilsVaultOrgPackApplicationName -Settings $Settings
Write-Log -Level 'INFO' -Message "Vault mode: loading RepoUtilsSecrets packs for $($scope.Organization)/$orgPackApplication and $($scope.Organization)/$($scope.Application)"
$mode = 'Rest'
try {
Import-RepoUtilsVaultClientModule
if (Get-Command Connect-Vault -ErrorAction SilentlyContinue) {
Connect-Vault -BaseAddress $connection.BaseAddress -ApiKey $connection.ApiKey
$mode = 'Cmdlet'
Write-Log -Level 'INFO' -Message 'Connected to Vault with MaksIT.Vault.Client.PowerShell'
}
else {
Write-Log -Level 'INFO' -Message 'MaksIT.Vault.Client.PowerShell not found; using Vault HTTP API'
}
}
catch {
Write-Log -Level 'INFO' -Message "Vault PowerShell module not loaded ($($_.Exception.Message)); using Vault HTTP API"
$mode = 'Rest'
}
if (-not $sharedPresent) {
$sharedValue = Resolve-RepoUtilsVaultSecretValue `
-OrganizationName $scope.Organization `
-ApplicationName $orgPackApplication `
-SecretName $names.SharedEnv `
-Connection $connection `
-Mode $mode
if ([string]::IsNullOrWhiteSpace($sharedValue)) {
$sharedValue = '{}'
Write-Log -Level 'INFO' -Message "Vault secret '$($names.SharedEnv)' was not found for $($scope.Organization)/$orgPackApplication; using empty org-pack."
}
[Environment]::SetEnvironmentVariable($names.SharedEnv, $sharedValue, 'Process')
}
if (-not $packPresent) {
$packValue = Resolve-RepoUtilsVaultSecretValue `
-OrganizationName $scope.Organization `
-ApplicationName $scope.Application `
-SecretName $names.PackEnv `
-Connection $connection `
-Mode $mode
if ([string]::IsNullOrWhiteSpace($packValue)) {
$packValue = '{}'
Write-Log -Level 'INFO' -Message "Vault secret '$($names.PackEnv)' was not found for $($scope.Organization)/$($scope.Application); using empty slug-pack."
}
[Environment]::SetEnvironmentVariable($names.PackEnv, $packValue, 'Process')
}
}
Export-ModuleMember -Function @(
'Test-RepoUtilsUseVaultEnabled',
'ConvertFrom-VaultConnectionSecret',
'Get-RepoUtilsVaultScope',
'Get-RepoUtilsVaultConnectionSecretName',
'Get-RepoUtilsVaultOrgPackApplicationName',
'Initialize-RepoUtilsVaultSecrets'
)

View File

@ -27,10 +27,6 @@ function Clear-ExternalCommandTestHandler {
$script:ExternalCommandTestHandler = $null $script:ExternalCommandTestHandler = $null
} }
function Test-ExternalCommandTestHandler {
return ($null -ne $script:ExternalCommandTestHandler)
}
function Set-ExternalCommandAvailability { function Set-ExternalCommandAvailability {
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
@ -111,7 +107,7 @@ function Invoke-ExternalCommand {
if ($ThrowOnError -and $exitCode -ne 0) { if ($ThrowOnError -and $exitCode -ne 0) {
$preview = ($output | ForEach-Object { $preview = ($output | ForEach-Object {
if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } else { [string]$_ } if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } else { [string]$_ }
} | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Last 12) -join ' ' } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 8) -join ' '
if ([string]::IsNullOrWhiteSpace($preview)) { if ([string]::IsNullOrWhiteSpace($preview)) {
throw "External command '$Name' failed with exit code $exitCode." throw "External command '$Name' failed with exit code $exitCode."
} }
@ -132,5 +128,4 @@ Export-ModuleMember -Function `
Invoke-ExternalCommand, ` Invoke-ExternalCommand, `
Set-ExternalCommandTestHandler, ` Set-ExternalCommandTestHandler, `
Clear-ExternalCommandTestHandler, ` Clear-ExternalCommandTestHandler, `
Test-ExternalCommandTestHandler, `
Set-ExternalCommandAvailability Set-ExternalCommandAvailability

View File

@ -0,0 +1,122 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
.NET artifact cleanup plugin — remove NuGet build outputs after release.
.DESCRIPTION
Removes files from the configured artifacts directory using glob patterns.
Defaults target NuGet outputs (*.nupkg, *.snupkg). Typically placed at the
end of the Release stage after DotNetCreateArchive or publish plugins.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/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 "DotNetCleanupArtifacts plugin requires an artifacts directory in the shared context."
}
if (-not (Test-Path $artifactsDirectory -PathType Container)) {
Write-Log -Level "WARN" -Message " Artifacts directory not found: $artifactsDirectory"
return
}
Write-Log -Level "STEP" -Message "Cleaning generated artifacts..."
$itemsToRemove = @()
foreach ($pattern in $patterns) {
$matchedItems = @(
Get-ChildItem -Path $artifactsDirectory -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like $pattern }
)
if ($excludePatterns.Count -gt 0) {
$matchedItems = @(
$matchedItems |
Where-Object {
$item = $_
-not ($excludePatterns | Where-Object { $item.Name -like $_ } | Select-Object -First 1)
}
)
}
$itemsToRemove += @($matchedItems)
}
$itemsToRemove = @($itemsToRemove | Sort-Object FullName -Unique)
if ($itemsToRemove.Count -eq 0) {
Write-Log -Level "INFO" -Message " No artifacts matched cleanup rules."
return
}
foreach ($item in $itemsToRemove) {
Remove-Item -Path $item.FullName -Recurse -Force -ErrorAction SilentlyContinue
Write-Log -Level "OK" -Message " Removed: $($item.Name)"
}
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -9,10 +9,6 @@
This plugin compresses .NET release artifact inputs prepared by an earlier This plugin compresses .NET release artifact inputs prepared by an earlier
DotNet plugin (DotNetPack or DotNetPublish) into a zip file DotNet plugin (DotNetPack or DotNetPublish) into a zip file
and exposes the resulting release assets for later publisher plugins. and exposes the resulting release assets for later publisher plugins.
For desktop apps with per-RID publish folders, the zip is the portable
build only (default win-x64). Windows installer and Flatpak artifacts are
separate GitHub assets and are never added to this zip.
#> #>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
@ -30,7 +26,7 @@ function Invoke-Plugin {
) )
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-ReleaseAssetPaths" Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
$pluginSettings = $Settings $pluginSettings = $Settings
$sharedSettings = $Settings.context $sharedSettings = $Settings.context
@ -53,34 +49,6 @@ function Invoke-Plugin {
} }
} }
$portableRid = $null
if ($pluginSettings.PSObject.Properties['portableRuntimeIdentifier'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.portableRuntimeIdentifier)) {
$portableRid = ([string]$pluginSettings.portableRuntimeIdentifier).Trim()
}
$publishOutputs = @(Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'publishOutputs' -Default @())
if ($publishOutputs.Count -gt 0) {
if ([string]::IsNullOrWhiteSpace($portableRid)) {
$portableRid = 'win-x64'
}
$portableOutputs = @(
$publishOutputs |
Where-Object { [string]$_.runtimeIdentifier -eq $portableRid }
)
if ($portableOutputs.Count -eq 0) {
$portableOutputs = @(
$publishOutputs |
Where-Object { ([string]$_.runtimeIdentifier).StartsWith('win-', [System.StringComparison]::OrdinalIgnoreCase) }
)
}
if ($portableOutputs.Count -gt 0) {
$archiveInputs = @($portableOutputs | ForEach-Object { [string]$_.directory })
Write-Log -Level "INFO" -Message " Portable zip uses runtime '$portableRid' only (installer/Flatpak stay out of the zip)."
}
}
if ($archiveInputs.Count -eq 0) { if ($archiveInputs.Count -eq 0) {
throw "DotNetCreateArchive plugin requires prepared artifacts. Run DotNetPack or DotNetPublish first." throw "DotNetCreateArchive plugin requires prepared artifacts. Run DotNetPack or DotNetPublish first."
} }
@ -116,20 +84,19 @@ function Invoke-Plugin {
Write-Log -Level "OK" -Message " Release archive ready: $zipPath" Write-Log -Level "OK" -Message " Release archive ready: $zipPath"
$newAssets = [System.Collections.Generic.List[string]]::new() $releaseAssetPaths = @($zipPath)
$newAssets.Add($zipPath)
$packageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -LegacyProperty @('packageFile') $packageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -LegacyProperty @('packageFile')
if ($null -ne $packageFile) { if ($null -ne $packageFile) {
$newAssets.Add($packageFile.FullName) $releaseAssetPaths += $packageFile.FullName
} }
$symbolsPackageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -LegacyProperty @('symbolsPackageFile') $symbolsPackageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -LegacyProperty @('symbolsPackageFile')
if ($null -ne $symbolsPackageFile) { if ($null -ne $symbolsPackageFile) {
$newAssets.Add($symbolsPackageFile.FullName) $releaseAssetPaths += $symbolsPackageFile.FullName
} }
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archivePath' -Value $zipPath -Overwrite Replace -LegacyProperty 'releaseArchivePath' Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archivePath' -Value $zipPath -Overwrite Replace -LegacyProperty 'releaseArchivePath'
Add-ReleaseAssetPaths -Context $sharedSettings -Path @($newAssets) Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'assetPaths' -Value $releaseAssetPaths -Overwrite Replace -LegacyProperty 'releaseAssetPaths'
} }
Export-ModuleMember -Function Invoke-Plugin Export-ModuleMember -Function Invoke-Plugin

View File

@ -29,10 +29,10 @@ function Invoke-Plugin {
$pluginSettings = $Settings $pluginSettings = $Settings
$sharedSettings = $Settings.context $sharedSettings = $Settings.context
$nugetSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'nugetSecret'
$packageFile = $sharedSettings.packageFile $packageFile = $sharedSettings.packageFile
$dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings
$nugetSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'nugetSecret' -PluginDisplayName 'DotNetNuGet' -Required
Assert-Command dotnet Assert-Command dotnet
@ -52,9 +52,13 @@ function Invoke-Plugin {
return return
} }
$nugetKey = Get-RepoUtilsSecretSlot -Name $nugetSecret -Settings $sharedSettings if ([string]::IsNullOrWhiteSpace($nugetSecret)) {
throw "DotNetNuGet plugin requires 'nugetSecret' in scriptSettings.json (logical secret name, e.g. NuGet)."
}
$nugetKey = Get-SecretEnvironmentValue -Name $nugetSecret
if ([string]::IsNullOrWhiteSpace($nugetKey)) { if ([string]::IsNullOrWhiteSpace($nugetKey)) {
throw "NuGet API key is not set. Set RepoUtilsSecrets slot '$nugetSecret' (nugetSecret)." throw "NuGet API key is not set. Set environment variable '$nugetSecret'."
} }
$nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) { $nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) {

View File

@ -6,14 +6,9 @@
.NET publish plugin for producing application release artifacts. .NET publish plugin for producing application release artifacts.
.DESCRIPTION .DESCRIPTION
This plugin publishes configured .NET projects into the artifacts directory This plugin publishes the configured .NET project into a release output
and appends those publish folders to shared archive inputs so later plugins directory and exposes that published directory to the shared release
can zip them next to any earlier pack outputs. Existing NuGet package facts context so later release-stage plugins can archive and publish it.
(packageFile) are left unchanged.
Optional runtimeIdentifiers (or runtimeIdentifier) produce per-RID folders
under artifactsDir/<ProjectName>/<rid> for desktop installers (Windows MSI /
Linux Flatpak). When RIDs are set, selfContained defaults to true.
#> #>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
@ -24,28 +19,6 @@ if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
} }
} }
function Get-DotNetPublishRuntimeIdentifiers {
param(
[Parameter(Mandatory = $true)]
$PluginSettings
)
$rids = [System.Collections.Generic.List[string]]::new()
if ($PluginSettings.PSObject.Properties['runtimeIdentifiers'] -and $null -ne $PluginSettings.runtimeIdentifiers) {
foreach ($item in @($PluginSettings.runtimeIdentifiers)) {
$value = [string]$item
if (-not [string]::IsNullOrWhiteSpace($value)) {
$rids.Add($value.Trim())
}
}
}
elseif ($PluginSettings.PSObject.Properties['runtimeIdentifier'] -and -not [string]::IsNullOrWhiteSpace([string]$PluginSettings.runtimeIdentifier)) {
$rids.Add(([string]$PluginSettings.runtimeIdentifier).Trim())
}
return @($rids)
}
function Invoke-Plugin { function Invoke-Plugin {
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
@ -55,145 +28,48 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact" Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
Import-PluginDependency -ModuleName "ExternalCommandSupport" -RequiredCommand "Invoke-ExternalCommand"
$pluginSettings = $Settings
$sharedSettings = $Settings.context $sharedSettings = $Settings.context
$scriptDir = $sharedSettings.scriptDir $projectFiles = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'projectFiles' -LegacyProperty @('projectFiles')
$projectFiles = @() $artifactsDirectory = $sharedSettings.artifactsDirectory
$publishProjectPath = $null
Assert-Command dotnet Assert-Command dotnet
if ($pluginSettings.PSObject.Properties['projectFiles'] -and $null -ne $pluginSettings.projectFiles) { if ($null -eq $projectFiles -or @($projectFiles).Count -eq 0) {
$projectFiles = @(Resolve-RelativePaths -Value $pluginSettings.projectFiles -BasePath $scriptDir) throw "DotNetPublish plugin requires project files in the shared context."
}
else {
$fromFact = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'projectFiles' -LegacyProperty @('projectFiles')
if ($null -ne $fromFact) {
$projectFiles = @($fromFact)
}
elseif ($sharedSettings.PSObject.Properties['projectFiles'] -and $null -ne $sharedSettings.projectFiles) {
$projectFiles = @($sharedSettings.projectFiles)
}
} }
if ($projectFiles.Count -eq 0) { $projectFiles = @($projectFiles)
throw "DotNetPublish plugin requires projectFiles in plugin settings or projectFiles on shared context."
}
if ($pluginSettings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.artifactsDir)) {
$artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$pluginSettings.artifactsDir)))
Set-EngineState -Context $sharedSettings -Name 'artifactsDirectory' -Value $artifactsDirectory
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
}
else {
$artifactsDirectory = $sharedSettings.artifactsDirectory
}
if ([string]::IsNullOrWhiteSpace([string]$artifactsDirectory)) {
throw "DotNetPublish plugin requires artifactsDir in plugin settings or artifactsDirectory on shared context."
}
if (!(Test-Path $artifactsDirectory)) { if (!(Test-Path $artifactsDirectory)) {
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
} }
$existing = Get-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -LegacyProperty @('releaseArchiveInputs') # The first configured project remains the canonical release artifact source.
$archiveInputs = [System.Collections.Generic.List[object]]::new() $publishProjectPath = $projectFiles[0]
if ($null -ne $existing) { $publishDir = Join-Path $artifactsDirectory ([System.IO.Path]::GetFileNameWithoutExtension($publishProjectPath))
foreach ($item in @($existing)) {
if ($null -ne $item) {
$archiveInputs.Add($item)
}
}
}
$runtimeIdentifiers = @(Get-DotNetPublishRuntimeIdentifiers -PluginSettings $pluginSettings)
$selfContained = $null
if ($pluginSettings.PSObject.Properties['selfContained'] -and $null -ne $pluginSettings.selfContained) {
$selfContained = [bool]$pluginSettings.selfContained
}
elseif ($runtimeIdentifiers.Count -gt 0) {
$selfContained = $true
}
$publishSingleFile = $false
if ($pluginSettings.PSObject.Properties['publishSingleFile'] -and $null -ne $pluginSettings.publishSingleFile) {
$publishSingleFile = [bool]$pluginSettings.publishSingleFile
}
$additionalArguments = @()
if ($pluginSettings.PSObject.Properties['additionalPublishArguments'] -and $null -ne $pluginSettings.additionalPublishArguments) {
$additionalArguments = @($pluginSettings.additionalPublishArguments | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
}
$publishOutputs = [System.Collections.Generic.List[object]]::new()
$ridLoop = @($null)
if ($runtimeIdentifiers.Count -gt 0) {
$ridLoop = @($runtimeIdentifiers)
}
foreach ($publishProjectPath in $projectFiles) {
$projectName = [System.IO.Path]::GetFileNameWithoutExtension($publishProjectPath)
$projectRoot = Join-Path $artifactsDirectory $projectName
foreach ($runtimeIdentifier in $ridLoop) {
$publishDir = if ([string]::IsNullOrWhiteSpace([string]$runtimeIdentifier)) {
$projectRoot
}
else {
Join-Path $projectRoot ([string]$runtimeIdentifier)
}
if (Test-Path $publishDir) { if (Test-Path $publishDir) {
Remove-Item -Path $publishDir -Recurse -Force Remove-Item -Path $publishDir -Recurse -Force
} }
$ridLabel = if ([string]::IsNullOrWhiteSpace([string]$runtimeIdentifier)) { 'current runtime' } else { [string]$runtimeIdentifier } Write-Log -Level "STEP" -Message "Publishing release artifact..."
Write-Log -Level "STEP" -Message "Publishing release artifact ($projectName / $ridLabel)..." dotnet publish $publishProjectPath -c Release -o $publishDir --nologo
$dotnetPublishArguments = @( if ($LASTEXITCODE -ne 0) {
'publish', $publishProjectPath, '-c', 'Release', '-o', $publishDir, '--nologo' throw "dotnet publish failed for $publishProjectPath."
)
if (-not [string]::IsNullOrWhiteSpace([string]$runtimeIdentifier)) {
$dotnetPublishArguments += @('-r', [string]$runtimeIdentifier)
} }
if ($null -ne $selfContained) {
if ($selfContained) {
$dotnetPublishArguments += '--self-contained'
}
else {
$dotnetPublishArguments += '--no-self-contained'
}
}
if ($publishSingleFile) {
$dotnetPublishArguments += '-p:PublishSingleFile=true'
}
if ($additionalArguments.Count -gt 0) {
$dotnetPublishArguments += $additionalArguments
}
Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetPublishArguments | Out-Null
$publishedItems = @(Get-ChildItem -Path $publishDir -Force -ErrorAction SilentlyContinue) $publishedItems = @(Get-ChildItem -Path $publishDir -Force -ErrorAction SilentlyContinue)
if ($publishedItems.Count -eq 0) { if ($publishedItems.Count -eq 0) {
throw "dotnet publish completed, but no files were produced in: $publishDir" throw "dotnet publish completed, but no files were produced in: $publishDir"
} }
Write-Log -Level "OK" -Message " Published artifact ready: $publishDir" Write-Log -Level "OK" -Message " Published artifact ready: $publishDir"
$archiveInputs.Add($publishDir)
$publishOutputs.Add([pscustomobject]@{ Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -Value $null -Overwrite Replace -LegacyProperty 'packageFile'
projectName = $projectName Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $null -Overwrite Replace -LegacyProperty 'symbolsPackageFile'
runtimeIdentifier = [string]$runtimeIdentifier Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value @($publishDir) -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
directory = $publishDir
})
}
} }
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value @($archiveInputs) -Overwrite Replace -LegacyProperty 'releaseArchiveInputs' Export-ModuleMember -Function Invoke-Plugin
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'publishOutputs' -Value @($publishOutputs) -Overwrite Replace
}
Export-ModuleMember -Function Invoke-Plugin, Get-DotNetPublishRuntimeIdentifiers

View File

@ -7,11 +7,9 @@
.DESCRIPTION .DESCRIPTION
Dedicated version-loading plugin. Reads <Version> from the first configured Dedicated version-loading plugin. Reads <Version> from the first configured
projectFiles .csproj, or from the nearest Directory.Build.props when the projectFiles entry and writes it (plus the resolved projectFiles) to the
csproj omits it. Accepts SemVer prerelease (0.1.0-alpha.1 / beta / rc). Writes shared runtime context. Declares providesVersion = $true so the engine can
version plus the resolved projectFiles (csproj paths for later pack/publish) discover it as the single release version source.
to the shared runtime context. Declares providesVersion = $true so the engine
can discover it as the single release version source.
#> #>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
@ -22,22 +20,6 @@ if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
} }
} }
function ConvertTo-MsbuildPropertyStringInternal {
param(
$Value
)
if ($null -eq $Value) {
return $null
}
if ($Value -is [System.Xml.XmlElement]) {
return [string]$Value.InnerText
}
return [string]$Value
}
function Get-CsprojPropertyValueInternal { function Get-CsprojPropertyValueInternal {
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
@ -54,33 +36,7 @@ function Get-CsprojPropertyValueInternal {
Select-Object -First 1 Select-Object -First 1
if ($propNode) { if ($propNode) {
return ConvertTo-MsbuildPropertyStringInternal -Value $propNode.$PropertyName return $propNode.$PropertyName
}
return $null
}
function Get-DirectoryBuildPropsVersionInternal {
param(
[Parameter(Mandatory = $true)]
[string]$ProjectPath
)
# MSBuild uses the first Directory.Build.props found walking up from the project directory.
$dir = [System.IO.Path]::GetDirectoryName((Resolve-Path -LiteralPath $ProjectPath))
while (-not [string]::IsNullOrWhiteSpace($dir)) {
$propsPath = Join-Path $dir 'Directory.Build.props'
if (Test-Path -LiteralPath $propsPath -PathType Leaf) {
[xml]$props = Get-Content -LiteralPath $propsPath
return Get-CsprojPropertyValueInternal -Csproj $props -PropertyName 'Version'
}
$parent = [System.IO.Directory]::GetParent($dir)
if ($null -eq $parent) {
break
}
$dir = $parent.FullName
} }
return $null return $null
@ -102,18 +58,14 @@ function Get-CsprojVersionInternal {
[xml]$csproj = Get-Content $ProjectPath [xml]$csproj = Get-Content $ProjectPath
$version = Get-CsprojPropertyValueInternal -Csproj $csproj -PropertyName "Version" $version = Get-CsprojPropertyValueInternal -Csproj $csproj -PropertyName "Version"
if (-not [string]::IsNullOrWhiteSpace([string]$version)) {
return [string]$version if ([string]::IsNullOrWhiteSpace([string]$version)) {
throw "DotNetReleaseVersion: <Version> not found in '$ProjectPath'."
} }
$version = Get-DirectoryBuildPropsVersionInternal -ProjectPath $ProjectPath
if (-not [string]::IsNullOrWhiteSpace([string]$version)) {
return [string]$version return [string]$version
} }
throw "DotNetReleaseVersion: <Version> not found in '$ProjectPath' or a parent Directory.Build.props."
}
function Get-PluginMetadata { function Get-PluginMetadata {
return [pscustomobject]@{ providesVersion = $true } return [pscustomobject]@{ providesVersion = $true }
} }
@ -135,10 +87,6 @@ function Invoke-Plugin {
Write-Log -Level "INFO" -Message "Reading version from SDK-style project file (projectFiles)..." Write-Log -Level "INFO" -Message "Reading version from SDK-style project file (projectFiles)..."
$version = Get-CsprojVersionInternal -ProjectPath $projectFiles[0] $version = Get-CsprojVersionInternal -ProjectPath $projectFiles[0]
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
if (-not (Test-ReleaseSemver -Version $version)) {
throw "DotNetReleaseVersion: version '$version' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
}
Set-EngineState -Context $shared -Name 'version' -Value $version Set-EngineState -Context $shared -Name 'version' -Value $version
Set-EngineFact -Context $shared -Namespace 'dotnet' -Name 'projectFiles' -Value $projectFiles -Overwrite Replace -LegacyProperty 'projectFiles' Set-EngineFact -Context $shared -Namespace 'dotnet' -Name 'projectFiles' -Value $projectFiles -Overwrite Replace -LegacyProperty 'projectFiles'

View File

@ -7,15 +7,12 @@
.DESCRIPTION .DESCRIPTION
Resolves one or more .NET test projects (`project` or `projects`), runs tests once Resolves one or more .NET test projects (`project` or `projects`), runs tests once
via TestRunner (Microsoft Testing Platform + coverlet.MTP only — no VSTest / via TestRunner, then publishes metrics on the shared engine context for any later
coverlet.collector), then publishes metrics on the shared engine context for any later
plugin: `qualityLineCoverage`, `testResult`, `coverageLineRate` / `coverageBranchRate` / `coverageMethodRate`, plugin: `qualityLineCoverage`, `testResult`, `coverageLineRate` / `coverageBranchRate` / `coverageMethodRate`,
method counts, `testResultsDirectory`, `coverageCoberturaPaths`. Quality gates read method counts, `testResultsDirectory`, `coverageCoberturaPaths`. Quality gates read
those keys generically (not tied to this plugin by name). When `resultsDir` (or the those keys generically (not tied to this plugin by name). When `resultsDir` (or the
multi-project default TestResults folder) is used, Cobertura output is kept on disk multi-project default TestResults folder) is used, Cobertura output is kept on disk
via TestRunner `-KeepResults` so repo-root `test-results/` persists after the run. via TestRunner `-KeepResults` so repo-root `test-results/` persists after the run.
Product solutions must place `src/global.json` with `test.runner` =
Microsoft.Testing.Platform next to the `.sln`/`.slnx`.
#> #>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {

View File

@ -0,0 +1,89 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Builds an npm workspace (install + build script).
.DESCRIPTION
Runs npm ci (or npm install when useCi is false) and npm run build in the
configured workspace root. Requires NpmReleaseVersion to have set
shared npmWorkspaceRoot unless workspaceRoot is configured explicitly.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/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"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
$pluginSettings = $Settings
$shared = $Settings.context
Assert-Command npm
$workspaceRoot = $null
if ($pluginSettings.workspaceRoot) {
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir)
$workspaceRoot = $workspaceRoots[0]
}
elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) {
$workspaceRoot = [string]$shared.npmWorkspaceRoot
}
else {
throw "NpmBuild plugin requires 'workspaceRoot' or a prior NpmReleaseVersion plugin run."
}
$useCi = $true
if ($null -ne $pluginSettings.useCi) {
$useCi = [bool]$pluginSettings.useCi
}
$buildScript = 'build'
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.buildScript)) {
$buildScript = [string]$pluginSettings.buildScript
}
Push-Location $workspaceRoot
try {
if ($useCi) {
Write-Log -Level "STEP" -Message "Running npm ci in '$workspaceRoot'..."
npm ci
if ($LASTEXITCODE -ne 0) {
throw "npm ci failed with exit code $LASTEXITCODE."
}
}
else {
Write-Log -Level "STEP" -Message "Running npm install in '$workspaceRoot'..."
npm install
if ($LASTEXITCODE -ne 0) {
throw "npm install failed with exit code $LASTEXITCODE."
}
}
Write-Log -Level "STEP" -Message "Running npm run $buildScript..."
npm run $buildScript
if ($LASTEXITCODE -ne 0) {
throw "npm run $buildScript failed with exit code $LASTEXITCODE."
}
Write-Log -Level "OK" -Message " npm build completed."
}
finally {
Pop-Location
}
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -0,0 +1,75 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
npm/Jest test plugin for the test engine.
.DESCRIPTION
Runs Jest with coverage via TestRunner.Invoke-NpmJestTestsWithCoverage and publishes
normalized metrics on the shared engine context for downstream plugins.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/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 "TestRunner" -RequiredCommand "Invoke-NpmJestTestsWithCoverage"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$scriptDir = $sharedSettings.scriptDir
Assert-Command npm
if (-not $pluginSettings.workspaceRoot) {
throw "NpmJestTest plugin requires 'workspaceRoot' in scriptSettings.json."
}
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $scriptDir)
$workspaceRoot = $workspaceRoots[0]
$testScript = 'test'
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.testScript)) {
$testScript = [string]$pluginSettings.testScript
}
$coverageDirectory = 'coverage'
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.coverageDirectory)) {
$coverageDirectory = [string]$pluginSettings.coverageDirectory
}
$testResult = Invoke-NpmJestTestsWithCoverage -WorkspaceRoot $workspaceRoot -TestScript $testScript -CoverageDirectory $coverageDirectory
if (-not $testResult.Success) {
throw "Tests failed. $($testResult.Error)"
}
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
Import-PluginDependency -ModuleName "TestRunner" -RequiredCommand "Publish-CoverageMetricsToSharedContext"
Set-EngineFact -Context $sharedSettings -Namespace 'npm' -Name 'workspaceRoot' -Value $workspaceRoot -Overwrite Replace -LegacyProperty 'npmWorkspaceRoot'
Publish-CoverageMetricsToSharedContext -SharedSettings $sharedSettings -TestResult $testResult
if (($testResult.PSObject.Properties.Name -contains 'CoverageSummaryFile') -and $testResult.CoverageSummaryFile) {
Set-EngineFact -Context $sharedSettings -Namespace 'test' -Name 'coverageSummaryFile' -Value $testResult.CoverageSummaryFile -Overwrite Replace -LegacyProperty 'coverageSummaryFile'
}
Write-Log -Level "OK" -Message " All tests passed!"
Write-Log -Level "INFO" -Message " Line Coverage: $($testResult.LineRate)%"
Write-Log -Level "INFO" -Message " Branch Coverage: $($testResult.BranchRate)%"
Write-Log -Level "INFO" -Message " Method Coverage: $($testResult.MethodRate)%"
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -0,0 +1,139 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Packs npm workspace packages into .tgz release artifacts.
.DESCRIPTION
Runs npm pack for each configured workspace package and publishes the
resulting tarball paths into shared release context (releaseAssetPaths)
for the GitHub release plugin.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/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"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
$pluginSettings = $Settings
$shared = $Settings.context
Assert-Command npm
$workspaceRoot = $null
if ($pluginSettings.workspaceRoot) {
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir)
$workspaceRoot = $workspaceRoots[0]
}
elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) {
$workspaceRoot = [string]$shared.npmWorkspaceRoot
}
else {
throw "NpmPack plugin requires 'workspaceRoot' or a prior NpmReleaseVersion plugin run."
}
$artifactsDirectory = $null
if ($pluginSettings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.artifactsDir)) {
$artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $shared.scriptDir ([string]$pluginSettings.artifactsDir)))
Set-EngineState -Context $shared -Name 'artifactsDirectory' -Value $artifactsDirectory
}
elseif ($shared.PSObject.Properties['artifactsDirectory'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.artifactsDirectory)) {
$artifactsDirectory = [string]$shared.artifactsDirectory
}
else {
throw "NpmPack plugin requires release-stage artifactsDirectory."
}
$packOrder = @()
if ($pluginSettings.publishOrder) {
if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) {
$packOrder = @($pluginSettings.publishOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
}
elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.publishOrder)) {
$packOrder = @([string]$pluginSettings.publishOrder)
}
}
elseif ($pluginSettings.packOrder) {
if ($pluginSettings.packOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.packOrder -is [string])) {
$packOrder = @($pluginSettings.packOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
}
elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.packOrder)) {
$packOrder = @([string]$pluginSettings.packOrder)
}
}
if ($packOrder.Count -eq 0) {
throw "NpmPack plugin requires non-empty 'publishOrder' or 'packOrder' (workspace package names)."
}
Import-Module (Join-Path $PSScriptRoot 'NpmPackageSupport.psm1') -Force
$useWorkspaces = Test-NpmWorkspacesConfigured -WorkspaceRoot $workspaceRoot
if (-not $useWorkspaces -and $packOrder.Count -gt 1) {
throw "NpmPack plugin requires npm workspaces when packing more than one package."
}
if (-not (Test-Path $artifactsDirectory -PathType Container)) {
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
}
$releaseAssetPaths = @()
Push-Location $workspaceRoot
try {
foreach ($packageName in $packOrder) {
Write-Log -Level "STEP" -Message "Packing npm package '$packageName'..."
if ($useWorkspaces) {
$tarballName = (npm pack -w $packageName --pack-destination $artifactsDirectory 2>$null | Select-Object -Last 1)
}
else {
Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName
$tarballName = (npm pack --pack-destination $artifactsDirectory 2>$null | Select-Object -Last 1)
}
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace([string]$tarballName)) {
throw "npm pack failed for '$packageName'."
}
$tarballPath = Join-Path $artifactsDirectory ([string]$tarballName).Trim()
if (-not (Test-Path $tarballPath -PathType Leaf)) {
throw "Could not locate pack output for '$packageName' at: $tarballPath"
}
$strayTarballPath = Join-Path $workspaceRoot ([string]$tarballName).Trim()
if ((Test-Path $strayTarballPath -PathType Leaf) -and $strayTarballPath -ne $tarballPath) {
Remove-Item -LiteralPath $strayTarballPath -Force -ErrorAction SilentlyContinue
}
Write-Log -Level "OK" -Message " Package ready: $tarballPath"
$releaseAssetPaths += $tarballPath
}
}
finally {
Pop-Location
}
Set-EngineState -Context $shared -Name 'releaseDir' -Value $artifactsDirectory
Set-EngineFact -Context $shared -Namespace 'release' -Name 'assetPaths' -Value $releaseAssetPaths -Overwrite Replace -LegacyProperty 'releaseAssetPaths'
if ($releaseAssetPaths.Count -gt 0) {
$packageItem = Get-Item -LiteralPath $releaseAssetPaths[0]
Set-EngineFact -Context $shared -Namespace 'npm' -Name 'packageFile' -Value $packageItem -Overwrite Replace -LegacyProperty 'packageFile'
}
Write-Log -Level "OK" -Message " npm pack completed ($($releaseAssetPaths.Count) tarball(s))."
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -0,0 +1,62 @@
#requires -Version 7.0
#requires -PSEdition Core
function Get-NpmRootPackageJson {
param(
[Parameter(Mandatory = $true)]
[string]$WorkspaceRoot
)
$packageJsonPath = Join-Path $WorkspaceRoot 'package.json'
if (-not (Test-Path $packageJsonPath -PathType Leaf)) {
throw "package.json not found at '$packageJsonPath'."
}
return Get-Content -Path $packageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
}
function Test-NpmWorkspacesConfigured {
param(
[Parameter(Mandatory = $true)]
[string]$WorkspaceRoot
)
$json = Get-NpmRootPackageJson -WorkspaceRoot $WorkspaceRoot
if (-not $json.PSObject.Properties['workspaces'] -or $null -eq $json.workspaces) {
return $false
}
$workspaces = $json.workspaces
if ($workspaces -is [string]) {
return -not [string]::IsNullOrWhiteSpace($workspaces)
}
if ($workspaces -is [System.Collections.IEnumerable]) {
$entries = @($workspaces | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
return $entries.Count -gt 0
}
return $false
}
function Assert-NpmRootPackageName {
param(
[Parameter(Mandatory = $true)]
[string]$WorkspaceRoot,
[Parameter(Mandatory = $true)]
[string]$ExpectedPackageName
)
$json = Get-NpmRootPackageJson -WorkspaceRoot $WorkspaceRoot
$rootPackageName = [string]$json.name
if ([string]::IsNullOrWhiteSpace($rootPackageName)) {
throw "Root package.json at '$WorkspaceRoot' is missing 'name'."
}
if ($rootPackageName -ne $ExpectedPackageName) {
throw "publishOrder package '$ExpectedPackageName' does not match root package name '$rootPackageName' (npm workspaces not configured)."
}
}
Export-ModuleMember -Function Test-NpmWorkspacesConfigured, Assert-NpmRootPackageName, Get-NpmRootPackageJson

View File

@ -0,0 +1,145 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Publishes npm workspace packages to the npm registry.
.DESCRIPTION
Publishes packages in configured order using npmSecret (logical secret name).
Pass the token via an environment variable named like the configured npm secret (e.g. Npm).
Uses a temporary .npmrc in the workspace root.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/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"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
$pluginSettings = $Settings
$shared = $Settings.context
$dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $shared
Assert-Command npm
$workspaceRoot = $null
if ($pluginSettings.workspaceRoot) {
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir)
$workspaceRoot = $workspaceRoots[0]
}
elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) {
$workspaceRoot = [string]$shared.npmWorkspaceRoot
}
else {
throw "NpmPublish plugin requires 'workspaceRoot' or a prior NpmReleaseVersion plugin run."
}
$registry = if ([string]::IsNullOrWhiteSpace([string]$pluginSettings.registry)) {
'https://registry.npmjs.org'
}
else {
[string]$pluginSettings.registry
}
$access = if ([string]::IsNullOrWhiteSpace([string]$pluginSettings.access)) {
'public'
}
else {
[string]$pluginSettings.access
}
$publishOrder = @()
if ($pluginSettings.publishOrder) {
if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) {
$publishOrder = @($pluginSettings.publishOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
}
elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.publishOrder)) {
$publishOrder = @([string]$pluginSettings.publishOrder)
}
}
if ($publishOrder.Count -eq 0) {
throw "NpmPublish plugin requires non-empty 'publishOrder' (workspace package names)."
}
Import-Module (Join-Path $PSScriptRoot 'NpmPackageSupport.psm1') -Force
$useWorkspaces = Test-NpmWorkspacesConfigured -WorkspaceRoot $workspaceRoot
if (-not $useWorkspaces -and $publishOrder.Count -gt 1) {
throw "NpmPublish plugin requires npm workspaces when publishing more than one package."
}
if ($dryRun) {
foreach ($packageName in $publishOrder) {
Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry"
}
return
}
$npmSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'npmSecret'
if ([string]::IsNullOrWhiteSpace($npmSecret)) {
throw "NpmPublish plugin requires 'npmSecret' in scriptSettings.json (logical secret name, e.g. Npm)."
}
$npmToken = Get-SecretEnvironmentValue -Name $npmSecret
if ([string]::IsNullOrWhiteSpace($npmToken)) {
throw "npm API key is not set. Set environment variable '$npmSecret'."
}
$registryHost = ([uri]$registry).Host
$tempNpmRcPath = Join-Path $workspaceRoot ".npmrc.release-temp"
$npmRcContent = @"
registry=$registry
//$registryHost/:_authToken=$npmToken
"@
Push-Location $workspaceRoot
try {
Set-Content -Path $tempNpmRcPath -Value $npmRcContent -Encoding UTF8 -NoNewline
foreach ($packageName in $publishOrder) {
Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..."
if ($useWorkspaces) {
npm publish -w $packageName --access $access --userconfig $tempNpmRcPath
}
else {
Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName
npm publish --access $access --userconfig $tempNpmRcPath
}
if ($LASTEXITCODE -ne 0) {
throw "Failed to publish npm package '$packageName'."
}
Write-Log -Level "OK" -Message " Published $packageName."
}
Write-Log -Level "OK" -Message " npm publish completed."
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-EnginePublishCompletion"
Add-EnginePublishCompletion -Context $shared -Publisher 'NpmPublish'
}
finally {
if (Test-Path $tempNpmRcPath -PathType Leaf) {
Remove-Item -Path $tempNpmRcPath -Force -ErrorAction SilentlyContinue
}
Pop-Location
}
}
function Get-PluginMetadata {
[pscustomobject]@{ mutatesRemote = $true }
}
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata

View File

@ -0,0 +1,104 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Loads npm workspace release version into shared context.
.DESCRIPTION
Reads semver from the configured workspace package.json and writes it to
shared context version. Optionally synchronizes version fields across
workspace package manifests before build/publish.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Get-PackageJsonVersionInternal {
param(
[Parameter(Mandatory = $true)]
[string]$PackageJsonPath
)
if (-not (Test-Path $PackageJsonPath -PathType Leaf)) {
throw "NpmReleaseVersion: package.json not found at '$PackageJsonPath'."
}
$json = Get-Content -Path $PackageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
$version = [string]$json.version
if ([string]::IsNullOrWhiteSpace($version)) {
throw "NpmReleaseVersion: 'version' is missing in '$PackageJsonPath'."
}
if ($version -notmatch '^\d+\.\d+\.\d+') {
throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver."
}
return $version
}
function Set-PackageJsonVersionInternal {
param(
[Parameter(Mandatory = $true)]
[string]$PackageJsonPath,
[Parameter(Mandatory = $true)]
[string]$Version
)
$raw = Get-Content -Path $PackageJsonPath -Raw -Encoding UTF8
$json = $raw | ConvertFrom-Json
$json.version = $Version
($json | ConvertTo-Json -Depth 100) + [Environment]::NewLine | Set-Content -Path $PackageJsonPath -Encoding UTF8 -NoNewline
}
function Get-PluginMetadata {
return [pscustomobject]@{ providesVersion = $true }
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
$pluginSettings = $Settings
$shared = $Settings.context
$packageJsonPaths = @(Resolve-RelativePaths -Value $pluginSettings.packageJsonPath -BasePath $shared.scriptDir)
if ($packageJsonPaths.Count -eq 0) {
throw "NpmReleaseVersion plugin requires 'packageJsonPath' in scriptSettings.json."
}
$packageJsonPath = $packageJsonPaths[0]
$version = Get-PackageJsonVersionInternal -PackageJsonPath $packageJsonPath
$syncWorkspaceVersions = $false
if ($null -ne $pluginSettings.syncWorkspaceVersions) {
$syncWorkspaceVersions = [bool]$pluginSettings.syncWorkspaceVersions
}
if ($syncWorkspaceVersions) {
$workspaceRoot = Split-Path -Parent $packageJsonPath
$packageManifests = Get-ChildItem -Path (Join-Path $workspaceRoot 'packages') -Recurse -Filter package.json -File -ErrorAction SilentlyContinue
foreach ($manifest in $packageManifests) {
Set-PackageJsonVersionInternal -PackageJsonPath $manifest.FullName -Version $version
}
Write-Log -Level "OK" -Message " Synchronized workspace package versions to $version."
}
$npmWorkspaceRoot = Split-Path -Parent $packageJsonPath
Set-EngineState -Context $shared -Name 'version' -Value $version
Set-EngineFact -Context $shared -Namespace 'npm' -Name 'workspaceRoot' -Value $npmWorkspaceRoot -Overwrite Replace -LegacyProperty 'npmWorkspaceRoot'
Set-EngineFact -Context $shared -Namespace 'npm' -Name 'packageJsonPath' -Value $packageJsonPath -Overwrite Replace -LegacyProperty 'npmPackageJsonPath'
Write-Log -Level "OK" -Message " Release version loaded by NpmReleaseVersion plugin: $version"
}
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata

View File

@ -1,78 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Collects coverage metrics from an existing test results directory.
.DESCRIPTION
Stack-agnostic parser for recovered container or host test output. Auto-detects .NET
Cobertura (coverage.cobertura.xml) or Jest (coverage-summary.json) and publishes the
same shared-context keys as DotNetTest / NpmJestTest for QualityGate.
Use after DockerContainerBuilder / PodmanContainerBuilder when tests run inside a container image.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/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 'TestRunner' -RequiredCommand 'Get-CoverageFromResultsDirectory'
Import-PluginDependency -ModuleName 'TestRunner' -RequiredCommand 'Publish-CoverageMetricsToSharedContext'
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$scriptDir = $sharedSettings.scriptDir
$resultsDirSetting = $pluginSettings.resultsDir
if ([string]::IsNullOrWhiteSpace($resultsDirSetting)) {
if ($sharedSettings.PSObject.Properties.Name -contains 'testResultsDirectory' -and $sharedSettings.testResultsDirectory) {
$resultsDirSetting = $sharedSettings.testResultsDirectory
}
else {
throw "CollectCoverage requires 'resultsDir' in plugin settings or testResultsDirectory on shared context (from ContainerBuilder)."
}
}
$resultsDirectory = if ([System.IO.Path]::IsPathRooted([string]$resultsDirSetting)) {
[string]$resultsDirSetting
}
else {
[System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$resultsDirSetting)))
}
$format = 'auto'
if ($pluginSettings.PSObject.Properties.Name -contains 'format' -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.format)) {
$format = [string]$pluginSettings.format
}
Write-Log -Level 'STEP' -Message "Collecting coverage from $resultsDirectory ..."
$testResult = Get-CoverageFromResultsDirectory -ResultsDirectory $resultsDirectory -Format $format -Silent:$true
if (-not $testResult.Success) {
throw $testResult.Error
}
Publish-CoverageMetricsToSharedContext -SharedSettings $sharedSettings -TestResult $testResult
$formatLabel = if ($testResult.CoverageFormat) { $testResult.CoverageFormat } else { 'unknown' }
Write-Log -Level 'OK' -Message " Coverage collected ($formatLabel)."
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)%"
}
function Get-PluginMetadata {
[pscustomobject]@{ mutatesRemote = $false }
}
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata

View File

@ -1,52 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Probes docker/podman availability and sets mandatory shared engine state for the pipeline.
.DESCRIPTION
Must be the first enabled plugin. Sets containerEngineProbeCompleted, activeContainerEngine,
dockerEngineAvailable, podmanEngineAvailable, and compose CLI fields on shared context.
Fails the pipeline when no usable engine is found.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/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 'ContainerEngineSupport' -RequiredCommand 'Resolve-ActiveContainerEngineState'
$shared = $Settings.context
Write-Log -Level 'STEP' -Message 'Probing container engine availability...'
$state = Resolve-ActiveContainerEngineState
Set-ContainerEngineSharedState -SharedSettings $shared -State $state
$display = Get-ContainerEngineDisplayName -Engine (Get-SharedContainerEngineInvocation -SharedSettings $shared)
Write-Log -Level 'OK' -Message " Active engine: $display"
Write-Log -Level 'INFO' -Message " docker CLI available: $($state.DockerEngineAvailable); podman CLI available: $($state.PodmanEngineAvailable)"
if ([string]::IsNullOrWhiteSpace([string]$state.ComposeExecutable)) {
Write-Log -Level 'INFO' -Message ' Compose CLI: not available (Compose plugins will fail if enabled).'
}
else {
Write-Log -Level 'INFO' -Message " Compose CLI: $($state.ComposeExecutable) $($state.ComposeBaseArgs -join ' ')"
}
}
function Get-PluginMetadata {
[pscustomobject]@{ mutatesRemote = $false }
}
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata

View File

@ -1,124 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Discovers package artifacts produced by pack plugins or container build pipelines.
.DESCRIPTION
Stack-agnostic scanner for artifactsDir. Detects .NET .nupkg/.snupkg or npm .tgz files
and populates shared context (packageFile, releaseAssetPaths, releaseArchiveInputs).
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/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 'DotNetArtifactSupport' -RequiredCommand 'Resolve-DotNetPackageArtifacts'
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$scriptDir = $sharedSettings.scriptDir
$version = $sharedSettings.version
if ($Settings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$Settings.artifactsDir)) {
$artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$Settings.artifactsDir)))
if (Get-Command Set-EngineState -ErrorAction SilentlyContinue) {
Set-EngineState -Context $sharedSettings -Name 'artifactsDirectory' -Value $artifactsDirectory
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
}
else {
$sharedSettings | Add-Member -NotePropertyName artifactsDirectory -NotePropertyValue $artifactsDirectory -Force
$sharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $artifactsDirectory -Force
}
}
else {
$artifactsDirectory = $sharedSettings.artifactsDirectory
}
if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) {
throw 'DiscoverPackageArtifacts requires artifactsDir in plugin settings or artifactsDirectory on shared context.'
}
$nupkgCandidates = @(Get-ChildItem -Path $artifactsDirectory -Filter '*.nupkg' -ErrorAction SilentlyContinue | Where-Object {
$_.Name -notlike '*.symbols.nupkg' -and $_.Name -notlike '*.snupkg'
})
$tgzCandidates = @(Get-ChildItem -Path $artifactsDirectory -Filter '*.tgz' -ErrorAction SilentlyContinue)
if ($nupkgCandidates.Count -gt 0 -and $tgzCandidates.Count -gt 0) {
throw "DiscoverPackageArtifacts found both NuGet and npm artifacts in $artifactsDirectory. Use one package stack per repository."
}
if ($nupkgCandidates.Count -gt 0) {
if ([string]::IsNullOrWhiteSpace([string]$version)) {
throw 'DiscoverPackageArtifacts requires release version on shared context when discovering .nupkg artifacts.'
}
Write-Log -Level 'STEP' -Message "Discovering NuGet package artifacts in $artifactsDirectory ..."
$resolved = Resolve-DotNetPackageArtifacts -ArtifactsDirectory $artifactsDirectory -Version $version
Write-Log -Level 'OK' -Message " Package ready: $($resolved.PackageFile.FullName)"
if ($resolved.SymbolsPackageFile) {
Write-Log -Level 'OK' -Message " Symbols package ready: $($resolved.SymbolsPackageFile.FullName)"
}
else {
Write-Log -Level 'WARN' -Message " Symbols package (.snupkg) not found for version $version."
}
$sharedSettings | Add-Member -NotePropertyName packageFile -NotePropertyValue $resolved.PackageFile -Force
$sharedSettings | Add-Member -NotePropertyName symbolsPackageFile -NotePropertyValue $resolved.SymbolsPackageFile -Force
$sharedSettings | Add-Member -NotePropertyName releaseArchiveInputs -NotePropertyValue $resolved.ReleaseArchiveInputs -Force
$sharedSettings | Add-Member -NotePropertyName releaseAssetPaths -NotePropertyValue $resolved.ReleaseArchiveInputs -Force
if (Get-Command Set-EngineFact -ErrorAction SilentlyContinue) {
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -Value $resolved.PackageFile -Overwrite Replace -LegacyProperty 'packageFile'
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $resolved.SymbolsPackageFile -Overwrite Replace -LegacyProperty 'symbolsPackageFile'
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'assetPaths' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseAssetPaths'
}
return
}
if ($tgzCandidates.Count -gt 0) {
Import-PluginDependency -ModuleName 'NpmArtifactSupport' -RequiredCommand 'Resolve-NpmPackageArtifacts'
Write-Log -Level 'STEP' -Message "Discovering npm package artifacts in $artifactsDirectory ..."
$resolved = Resolve-NpmPackageArtifacts -ArtifactsDirectory $artifactsDirectory -Version $version
foreach ($tarball in $resolved.PackageFiles) {
Write-Log -Level 'OK' -Message " Package ready: $($tarball.FullName)"
}
$sharedSettings | Add-Member -NotePropertyName packageFile -NotePropertyValue $resolved.PackageFile -Force
$sharedSettings | Add-Member -NotePropertyName releaseArchiveInputs -NotePropertyValue $resolved.ReleaseArchiveInputs -Force
$sharedSettings | Add-Member -NotePropertyName releaseAssetPaths -NotePropertyValue $resolved.ReleaseAssetPaths -Force
$sharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $artifactsDirectory -Force
if (Get-Command Set-EngineFact -ErrorAction SilentlyContinue) {
Set-EngineFact -Context $sharedSettings -Namespace 'npm' -Name 'packageFile' -Value $resolved.PackageFile -Overwrite Replace -LegacyProperty 'packageFile'
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'assetPaths' -Value $resolved.ReleaseAssetPaths -Overwrite Replace -LegacyProperty 'releaseAssetPaths'
if (Get-Command Set-EngineState -ErrorAction SilentlyContinue) {
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
}
}
return
}
throw "DiscoverPackageArtifacts found no .nupkg or .tgz files in: $artifactsDirectory"
}
function Get-PluginMetadata {
[pscustomobject]@{ mutatesRemote = $false }
}
Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata

View File

@ -7,10 +7,9 @@
.DESCRIPTION .DESCRIPTION
Reads a single-line semver from the configured versionFilePath (default Reads a single-line semver from the configured versionFilePath (default
repo-root VERSION), including optional prerelease (0.1.0-alpha.1). Useful for repo-root VERSION). Useful for repositories without .csproj or package.json
repositories without .csproj or package.json version metadata. Declares version metadata. Declares providesVersion = $true so the engine can
providesVersion = $true so the engine can discover it as the single release discover it as the single release version source.
version source.
#> #>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
@ -37,9 +36,8 @@ function Get-VersionFileSemverInternal {
} }
$version = $version -replace '^[vV]', '' $version = $version -replace '^[vV]', ''
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver" if ($version -notmatch '^\d+\.\d+\.\d+') {
if (-not (Test-ReleaseSemver -Version $version)) { throw "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver."
throw "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
} }
return $version return $version
@ -57,7 +55,6 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState" Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
$shared = $Settings.context $shared = $Settings.context
$versionFileSetting = if ($Settings.versionFilePath) { $versionFileSetting = if ($Settings.versionFilePath) {

View File

@ -10,8 +10,7 @@
repository, and creates the configured GitHub release using the repository, and creates the configured GitHub release using the
shared release artifacts and release notes from CHANGELOG.md. shared release artifacts and release notes from CHANGELOG.md.
Release notes must use Keep a Changelog headers: ## [semver] - YYYY-MM-DD Release notes must use Keep a Changelog headers: ## [semver] - YYYY-MM-DD
(including optional SemVer prerelease, e.g. ## [0.1.0-alpha.1] / [0.1.0-beta.1] / [0.1.0-rc.1]; (see ChangelogSupport.psm1).
see ChangelogSupport.psm1). Hyphenated versions are created with gh --prerelease.
#> #>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
@ -96,11 +95,11 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-LatestChangelogVersion" Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-LatestChangelogVersion"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemverPrerelease"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Get-EngineFact" Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Get-EngineFact"
$pluginSettings = $Settings $pluginSettings = $Settings
$sharedSettings = $Settings.context $sharedSettings = $Settings.context
$githubSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'githubSecret'
$configuredRepository = $pluginSettings.repository $configuredRepository = $pluginSettings.repository
$releaseNotesFileSetting = $pluginSettings.releaseNotesFile $releaseNotesFileSetting = $pluginSettings.releaseNotesFile
$releaseTitlePatternSetting = $pluginSettings.releaseTitlePattern $releaseTitlePatternSetting = $pluginSettings.releaseTitlePattern
@ -111,7 +110,6 @@ function Invoke-Plugin {
$releaseAssetPaths = @() $releaseAssetPaths = @()
$dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings
$githubSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'githubSecret' -PluginDisplayName 'GitHub' -Required
if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) { if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) {
throw "GitHub plugin requires 'releaseNotesFile' in scriptSettings.json." throw "GitHub plugin requires 'releaseNotesFile' in scriptSettings.json."
@ -130,17 +128,18 @@ function Invoke-Plugin {
} }
$releaseName = $releaseTitlePattern -replace '\{version\}', $version $releaseName = $releaseTitlePattern -replace '\{version\}', $version
Write-Log -Level "INFO" -Message "Dry run: would create GitHub release '$releaseName' ($tag) on $repo" Write-Log -Level "INFO" -Message "Dry run: would create GitHub release '$releaseName' ($tag) on $repo"
if (Test-ReleaseSemverPrerelease -Version ([string]$version)) {
Write-Log -Level "INFO" -Message "Dry run: release would be marked prerelease."
}
return return
} }
Assert-Command gh Assert-Command gh
$ghToken = Get-RepoUtilsSecretSlot -Name $githubSecret -Settings $sharedSettings if ([string]::IsNullOrWhiteSpace($githubSecret)) {
throw "GitHub plugin requires 'githubSecret' in scriptSettings.json (logical secret name, e.g. GitHub)."
}
$ghToken = Get-SecretEnvironmentValue -Name $githubSecret
if ([string]::IsNullOrWhiteSpace($ghToken)) { if ([string]::IsNullOrWhiteSpace($ghToken)) {
throw "GitHub token is not set. Set RepoUtilsSecrets slot '$githubSecret' (githubSecret)." throw "GitHub token is not set. Set environment variable '$githubSecret'."
} }
if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) { if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) {
@ -231,7 +230,7 @@ function Invoke-Plugin {
$authStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" } $authStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" }
} }
throw "GitHub CLI authentication failed for repository '$repo'. Ensure RepoUtilsSecrets slot '$githubSecret' is valid and has access to this repository." throw "GitHub CLI authentication failed for repository '$repo'. Ensure secret '$githubSecret' 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 "OK" -Message " GitHub token validated for repository: $($authOutput | Select-Object -First 1)"
@ -262,10 +261,6 @@ function Invoke-Plugin {
"--title", $releaseName, "--title", $releaseName,
"--notes-file", $notesFilePath "--notes-file", $notesFilePath
) )
if (Test-ReleaseSemverPrerelease -Version ([string]$version)) {
$createReleaseArgs += '--prerelease'
}
& gh @createReleaseArgs & gh @createReleaseArgs
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {

View File

@ -11,9 +11,8 @@
when they do not (whenRequirementsNotMet: skip). Publish plugins no longer use per-plugin when they do not (whenRequirementsNotMet: skip). Publish plugins no longer use per-plugin
branch lists; put allowed branches here instead. branch lists; put allowed branches here instead.
Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD,
(vX.Y.Z or vX.Y.Z-prerelease such as v0.1.0-alpha.1 / v0.1.0-beta.1 / v0.1.0-rc.1), tag version vs DotNetReleaseVersion, optional push tag to remote.
tag version vs release version, optional push tag to remote.
The engine preflight no longer reads git tags; this plugin sets context.tag from the The engine preflight no longer reads git tags; this plugin sets context.tag from the
git tag on HEAD when required. Shared context version always remains from DotNetReleaseVersion. git tag on HEAD when required. Shared context version always remains from DotNetReleaseVersion.
@ -78,7 +77,6 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Get-GitStatusShort" Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Get-GitStatusShort"
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Test-RemoteTagExists" Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Test-RemoteTagExists"
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Push-TagToRemote" Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Push-TagToRemote"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ChangelogSemverPattern"
$pluginSettings = $Settings $pluginSettings = $Settings
$shared = $Settings.context $shared = $Settings.context
@ -125,9 +123,8 @@ function Invoke-Plugin {
return return
} }
$tagPattern = '^v(' + (Get-ChangelogSemverPattern) + ')$' if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') {
if ($tag -notmatch $tagPattern) { Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z."
Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z or vX.Y.Z-prerelease (e.g. v0.1.0-alpha.1, v0.1.0-beta.1, v0.1.0-rc.1)."
return return
} }