(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] ## [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 ## [1.0.0] - 2026-08-17
### Added ### Added

View File

@ -90,6 +90,8 @@ 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://github.com/MAKS-IT-COM/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`.

View File

@ -4,7 +4,7 @@
![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-41.4%25-yellowgreen) ![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) ![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 ## 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. `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 | | 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-nats** only. 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`: Or in your `.csproj`:
```xml ```xml
<PackageReference Include="MaksIT.Persistence" Version="1.0.0" /> <PackageReference Include="MaksIT.Persistence" Version="2.0.0" />
``` ```
## Registering services ## Registering services
@ -73,10 +72,9 @@ 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);
``` ```
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 ```csharp
await app.Services.EnsureLinq2DbMigratedAsync(); await app.Services.EnsureLinq2DbMigratedAsync();
@ -92,7 +90,6 @@ 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

@ -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(" ")); 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,26 +71,4 @@ 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 migrations and schema sync. /// Host startup helpers for FluentMigrator.
/// </summary> /// </summary>
public static class ServiceProviderExtensions { public static class ServiceProviderExtensions {
/// <summary> /// <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>. /// Call from Program.cs before <c>RunAsync</c>.
/// </summary> /// </summary>
public static async Task EnsureLinq2DbMigratedAsync( public static async Task EnsureLinq2DbMigratedAsync(
@ -22,9 +22,5 @@ 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, schema sync). /// Optional hooks for reporting database startup phase timing (migrations).
/// </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 and schema sync.</summary> /// <summary>Well-known phase names reported during migrations.</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,9 +33,6 @@ 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

@ -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> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<PackageId>MaksIT.Persistence</PackageId> <PackageId>MaksIT.Persistence</PackageId>
<Version>1.0.0</Version> <Version>2.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, 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> <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>

View File

@ -6,16 +6,65 @@
Keep a Changelog header parsing and section extraction. Keep a Changelog header parsing and section extraction.
.DESCRIPTION .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 ## [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+\[(\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 { 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 { function Get-LatestChangelogVersion {
@ -53,4 +102,4 @@ function Get-ChangelogReleaseNotesSection {
return $match.Value.Trim() 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 .DESCRIPTION
Dedicated version-loading plugin. Reads <Version> from the first configured Dedicated version-loading plugin. Reads <Version> from the first configured
projectFiles entry and writes it (plus the resolved projectFiles) to the projectFiles .csproj, or from the nearest Directory.Build.props when the
shared runtime context. Declares providesVersion = $true so the engine can csproj omits it. Accepts SemVer prerelease (0.1.0-alpha.1 / beta / rc). Writes
discover it as the single release version source. 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)) { 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 { function Get-CsprojPropertyValueInternal {
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
@ -36,7 +54,33 @@ function Get-CsprojPropertyValueInternal {
Select-Object -First 1 Select-Object -First 1
if ($propNode) { 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 return $null
@ -58,12 +102,16 @@ 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)) {
if ([string]::IsNullOrWhiteSpace([string]$version)) { return [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 {
@ -87,6 +135,10 @@ 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

@ -28,6 +28,7 @@ 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 "Resolve-RelativePaths" Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ReleaseSemverPrereleaseLabel"
$pluginSettings = $Settings $pluginSettings = $Settings
$shared = $Settings.context $shared = $Settings.context
@ -62,6 +63,14 @@ function Invoke-Plugin {
[string]$pluginSettings.access [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 = @() $publishOrder = @()
if ($pluginSettings.publishOrder) { if ($pluginSettings.publishOrder) {
if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) { if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) {
@ -84,7 +93,8 @@ function Invoke-Plugin {
if ($dryRun) { if ($dryRun) {
foreach ($packageName in $publishOrder) { 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 return
} }
@ -112,13 +122,20 @@ registry=$registry
foreach ($packageName in $publishOrder) { foreach ($packageName in $publishOrder) {
Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..." Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..."
$publishArgs = @('publish')
if ($useWorkspaces) { if ($useWorkspaces) {
npm publish -w $packageName --access $access --userconfig $tempNpmRcPath $publishArgs += @('-w', $packageName)
} }
else { else {
Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName 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) { if ($LASTEXITCODE -ne 0) {
throw "Failed to publish npm package '$packageName'." throw "Failed to publish npm package '$packageName'."

View File

@ -35,8 +35,9 @@ function Get-PackageJsonVersionInternal {
throw "NpmReleaseVersion: 'version' is missing in '$PackageJsonPath'." throw "NpmReleaseVersion: 'version' is missing in '$PackageJsonPath'."
} }
if ($version -notmatch '^\d+\.\d+\.\d+') { Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver." 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 return $version
@ -69,6 +70,7 @@ 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"
$pluginSettings = $Settings $pluginSettings = $Settings
$shared = $Settings.context $shared = $Settings.context

View File

@ -7,9 +7,10 @@
.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). Useful for repositories without .csproj or package.json repo-root VERSION), including optional prerelease (0.1.0-alpha.1). Useful for
version metadata. Declares providesVersion = $true so the engine can repositories without .csproj or package.json version metadata. Declares
discover it as the single release version source. 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)) {
@ -36,8 +37,9 @@ function Get-VersionFileSemverInternal {
} }
$version = $version -replace '^[vV]', '' $version = $version -replace '^[vV]', ''
if ($version -notmatch '^\d+\.\d+\.\d+') { Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
throw "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver." 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 return $version
@ -55,6 +57,7 @@ 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,7 +10,8 @@
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
(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)) { 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 "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
@ -128,6 +130,9 @@ 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
} }
@ -261,6 +266,10 @@ 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,8 +11,9 @@
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
tag version vs DotNetReleaseVersion, optional push tag to remote. (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 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.
@ -77,6 +78,7 @@ 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
@ -123,8 +125,9 @@ function Invoke-Plugin {
return return
} }
if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') { $tagPattern = '^v(' + (Get-ChangelogSemverPattern) + ')$'
Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z." 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 return
} }