(refactor): remove schema sync

This commit is contained in:
Maksym Sadovnychyy 2026-08-21 16:59:21 +02:00
parent 626cccd34f
commit 4a79a98b98
22 changed files with 179 additions and 276 deletions

View File

@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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
### Added

View File

@ -90,6 +90,8 @@ This project follows [Semantic Versioning](https://semver.org/):
- **MINOR**: backward-compatible features
- **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
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`.

View File

@ -4,7 +4,7 @@
![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-41.4%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, add-only schema sync, and `MaksIT.Results` guards.
NuGet facade over [linq2db](https://linq2db.github.io/) + PostgreSQL for ASP.NET Core Engines: connection factory, FluentMigrator startup, and `MaksIT.Results` guards.
## Table of Contents
@ -22,13 +22,12 @@ 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.
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.
Linq2Db queries stay in the product: persist/query services still call `GetTable<T>()`. This library hides connection lifetime, Postgres transients, and FluentMigrator bootstrap.
| Need | Register | Inject |
|------|----------|--------|
| Scoped `DataConnection` | `RegisterLinq2Db(connectionString, mappingSchema?)` | `ILinq2DbConnectionFactory` |
| 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-nats** only.
@ -55,7 +54,7 @@ dotnet add package MaksIT.Persistence
Or in your `.csproj`:
```xml
<PackageReference Include="MaksIT.Persistence" Version="1.0.0" />
<PackageReference Include="MaksIT.Persistence" Version="2.0.0" />
```
## Registering services
@ -73,10 +72,9 @@ services.RegisterPostgresMigrations(
EnsureCoordinationTables = ct => CoordinationTableProvisioner.EnsureAsync(config.ConnectionString, ct),
VerifySchema = ct => VerifyCoreSchemaAsync(config.ConnectionString, ct),
});
services.RegisterSchemaSync(config.ConnectionString, ProductDesiredSchema.Map, config.AutoSyncSchema);
```
Host `Program.cs` before `RunAsync`:
Schema changes go in a new FluentMigrator `[Migration]` (`Create.Table` / `Alter.Table.AddColumn`; Drop/rename needs a separate decision). Host `Program.cs` before `RunAsync`:
```csharp
await app.Services.EnsureLinq2DbMigratedAsync();
@ -92,7 +90,6 @@ var row = db.GetTable<UserDto>().FirstOrDefault(u => u.Id == id);
## What stays in the product
- Table names, Dto, FluentMapping entity list, FluentMigrator `[Migration]` classes
- Desired schema map for SchemaSync
- Coordination DDL unique to the product (hook on `PostgresMigrationOptions`)
- `I*PersistenceService` / `I*QueryService` and domain services
- HA work leases (Dapr or HAMode)

View File

@ -1,32 +0,0 @@
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,19 +31,6 @@ public class ServiceCollectionExtensionsTests {
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]
public void RegisterPostgresMigrations_RegistersRunner() {
var services = new ServiceCollection();

View File

@ -71,26 +71,4 @@ public static class ServiceCollectionExtensions {
.AddLogging(lb => lb.AddFluentMigratorConsole());
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;
/// <summary>
/// Host startup helpers for migrations and schema sync.
/// Host startup helpers for FluentMigrator.
/// </summary>
public static class ServiceProviderExtensions {
/// <summary>
/// Runs <see cref="IRunMigrationsService"/> then <see cref="ISchemaSyncService"/> when registered.
/// Runs <see cref="IRunMigrationsService"/> when registered.
/// Call from Program.cs before <c>RunAsync</c>.
/// </summary>
public static async Task EnsureLinq2DbMigratedAsync(
@ -22,9 +22,5 @@ public static class ServiceProviderExtensions {
var run = sp.GetService<IRunMigrationsService>();
if (run is not null)
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;
/// <summary>
/// Optional hooks for reporting database startup phase timing (migrations, schema sync).
/// Optional hooks for reporting database startup phase timing (migrations).
/// </summary>
public interface IDatabaseStartupObserver {
/// <summary>Called when a named startup phase begins.</summary>
@ -14,7 +14,7 @@ public interface IDatabaseStartupObserver {
void OnPhaseFailed(string phase, TimeSpan elapsed, Exception exception);
}
/// <summary>Well-known phase names reported during migrations and schema sync.</summary>
/// <summary>Well-known phase names reported during migrations.</summary>
public static class DatabaseStartupPhases {
/// <summary>Maintenance Postgres (database postgres) accepted connections.</summary>
public const string PostgresMaintenanceReady = "postgres_maintenance_ready";
@ -33,9 +33,6 @@ public static class DatabaseStartupPhases {
/// <summary>Product schema verification completed.</summary>
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>

View File

@ -1,13 +0,0 @@
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

@ -1,9 +0,0 @@
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

@ -1,9 +0,0 @@
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

@ -1,35 +0,0 @@
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

@ -1,13 +0,0 @@
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

@ -1,83 +0,0 @@
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>
<PackageId>MaksIT.Persistence</PackageId>
<Version>1.0.0</Version>
<Version>2.0.0</Version>
<Authors>Maksym Sadovnychyy</Authors>
<Company>MAKS-IT</Company>
<Product>MaksIT.Persistence</Product>
<Description>MaksIT.Persistence product-agnostic facades for linq2db PostgreSQL: connection factory, FluentMigrator startup, add-only schema sync, and Result guards.</Description>
<Description>MaksIT.Persistence product-agnostic facades for linq2db PostgreSQL: connection factory, FluentMigrator startup, and Result guards.</Description>
<PackageTags>dotnet;linq2db;postgresql;fluentmigrator;</PackageTags>
<RepositoryUrl>https://github.com/MAKS-IT-COM/maksit-persistence</RepositoryUrl>
<License>MIT</License>

View File

@ -6,16 +6,65 @@
Keep a Changelog header parsing and section extraction.
.DESCRIPTION
Supports only the standard Keep a Changelog version line:
Supports Keep a Changelog version lines and shared SemVer checks, including prerelease:
## [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 {
return '(?m)^##\s+\[(\d+\.\d+\.\d+)\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
return '(?m)^##\s+\[(' + (Get-ChangelogSemverPattern) + ')\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
}
function Get-ChangelogNextVersionHeaderPattern {
return '(?m)^##\s+\[\d+\.\d+\.\d+\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
return '(?m)^##\s+\[' + (Get-ChangelogSemverPattern) + '\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
}
function Get-LatestChangelogVersion {
@ -53,4 +102,4 @@ function Get-ChangelogReleaseNotesSection {
return $match.Value.Trim()
}
Export-ModuleMember -Function Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection
Export-ModuleMember -Function Get-ChangelogSemverPattern, Test-ReleaseSemver, Test-ReleaseSemverPrerelease, Get-ReleaseSemverPrereleaseLabel, Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection

View File

@ -7,9 +7,11 @@
.DESCRIPTION
Dedicated version-loading plugin. Reads <Version> from the first configured
projectFiles entry and writes it (plus the resolved projectFiles) to the
shared runtime context. Declares providesVersion = $true so the engine can
discover it as the single release version source.
projectFiles .csproj, or from the nearest Directory.Build.props when the
csproj omits it. Accepts SemVer prerelease (0.1.0-alpha.1 / beta / rc). Writes
version plus the resolved projectFiles (csproj paths for later pack/publish)
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)) {
@ -20,6 +22,22 @@ 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 {
param(
[Parameter(Mandatory = $true)]
@ -36,7 +54,33 @@ function Get-CsprojPropertyValueInternal {
Select-Object -First 1
if ($propNode) {
return $propNode.$PropertyName
return ConvertTo-MsbuildPropertyStringInternal -Value $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
@ -58,12 +102,16 @@ function Get-CsprojVersionInternal {
[xml]$csproj = Get-Content $ProjectPath
$version = Get-CsprojPropertyValueInternal -Csproj $csproj -PropertyName "Version"
if ([string]::IsNullOrWhiteSpace([string]$version)) {
throw "DotNetReleaseVersion: <Version> not found in '$ProjectPath'."
if (-not [string]::IsNullOrWhiteSpace([string]$version)) {
return [string]$version
}
$version = Get-DirectoryBuildPropsVersionInternal -ProjectPath $ProjectPath
if (-not [string]::IsNullOrWhiteSpace([string]$version)) {
return [string]$version
}
throw "DotNetReleaseVersion: <Version> not found in '$ProjectPath' or a parent Directory.Build.props."
}
function Get-PluginMetadata {
@ -87,6 +135,10 @@ function Invoke-Plugin {
Write-Log -Level "INFO" -Message "Reading version from SDK-style project file (projectFiles)..."
$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-EngineFact -Context $shared -Namespace 'dotnet' -Name 'projectFiles' -Value $projectFiles -Overwrite Replace -LegacyProperty 'projectFiles'

View File

@ -28,6 +28,7 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ReleaseSemverPrereleaseLabel"
$pluginSettings = $Settings
$shared = $Settings.context
@ -62,6 +63,14 @@ function Invoke-Plugin {
[string]$pluginSettings.access
}
$npmDistTag = $null
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.npmDistTag)) {
$npmDistTag = [string]$pluginSettings.npmDistTag
}
else {
$npmDistTag = Get-ReleaseSemverPrereleaseLabel -Version ([string]$shared.version)
}
$publishOrder = @()
if ($pluginSettings.publishOrder) {
if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) {
@ -84,7 +93,8 @@ function Invoke-Plugin {
if ($dryRun) {
foreach ($packageName in $publishOrder) {
Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry"
$tagNote = if ([string]::IsNullOrWhiteSpace($npmDistTag)) { 'latest' } else { $npmDistTag }
Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry (dist-tag $tagNote)"
}
return
}
@ -112,13 +122,20 @@ registry=$registry
foreach ($packageName in $publishOrder) {
Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..."
$publishArgs = @('publish')
if ($useWorkspaces) {
npm publish -w $packageName --access $access --userconfig $tempNpmRcPath
$publishArgs += @('-w', $packageName)
}
else {
Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName
npm publish --access $access --userconfig $tempNpmRcPath
}
$publishArgs += @('--access', $access, '--userconfig', $tempNpmRcPath)
if (-not [string]::IsNullOrWhiteSpace($npmDistTag)) {
$publishArgs += @('--tag', $npmDistTag)
Write-Log -Level "INFO" -Message " Using npm dist-tag '$npmDistTag' (prerelease)."
}
npm @publishArgs
if ($LASTEXITCODE -ne 0) {
throw "Failed to publish npm package '$packageName'."

View File

@ -35,8 +35,9 @@ function Get-PackageJsonVersionInternal {
throw "NpmReleaseVersion: 'version' is missing in '$PackageJsonPath'."
}
if ($version -notmatch '^\d+\.\d+\.\d+') {
throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver."
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
if (-not (Test-ReleaseSemver -Version $version)) {
throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
}
return $version
@ -69,6 +70,7 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
$pluginSettings = $Settings
$shared = $Settings.context

View File

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

View File

@ -10,7 +10,8 @@
repository, and creates the configured GitHub release using the
shared release artifacts and release notes from CHANGELOG.md.
Release notes must use Keep a Changelog headers: ## [semver] - YYYY-MM-DD
(see ChangelogSupport.psm1).
(including optional SemVer prerelease, e.g. ## [0.1.0-alpha.1] / [0.1.0-beta.1] / [0.1.0-rc.1];
see ChangelogSupport.psm1). Hyphenated versions are created with gh --prerelease.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
@ -95,6 +96,7 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-LatestChangelogVersion"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemverPrerelease"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Get-EngineFact"
$pluginSettings = $Settings
@ -128,6 +130,9 @@ function Invoke-Plugin {
}
$releaseName = $releaseTitlePattern -replace '\{version\}', $version
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
}
@ -261,6 +266,10 @@ function Invoke-Plugin {
"--title", $releaseName,
"--notes-file", $notesFilePath
)
if (Test-ReleaseSemverPrerelease -Version ([string]$version)) {
$createReleaseArgs += '--prerelease'
}
& gh @createReleaseArgs
if ($LASTEXITCODE -ne 0) {

View File

@ -11,8 +11,9 @@
when they do not (whenRequirementsNotMet: skip). Publish plugins no longer use per-plugin
branch lists; put allowed branches here instead.
Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD,
tag version vs DotNetReleaseVersion, optional push tag to remote.
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 release version, optional push tag to remote.
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.
@ -77,6 +78,7 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Get-GitStatusShort"
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Test-RemoteTagExists"
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Push-TagToRemote"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ChangelogSemverPattern"
$pluginSettings = $Settings
$shared = $Settings.context
@ -123,8 +125,9 @@ function Invoke-Plugin {
return
}
if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') {
Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z."
$tagPattern = '^v(' + (Get-ChangelogSemverPattern) + ')$'
if ($tag -notmatch $tagPattern) {
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
}