diff --git a/CHANGELOG.md b/CHANGELOG.md index 792fb8f..d09a385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 556bf26..05e3f0a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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`. diff --git a/README.md b/README.md index 7c4fafc..b1f8a08 100644 --- a/README.md +++ b/README.md @@ -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()`. 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()`. 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 - + ``` ## 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().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) diff --git a/src/MaksIT.Persistence.Tests/SchemaSyncDdlBuilderTests.cs b/src/MaksIT.Persistence.Tests/SchemaSyncDdlBuilderTests.cs deleted file mode 100644 index 2553982..0000000 --- a/src/MaksIT.Persistence.Tests/SchemaSyncDdlBuilderTests.cs +++ /dev/null @@ -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>(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>(StringComparer.OrdinalIgnoreCase) { - ["users"] = new HashSet(["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")); -} \ No newline at end of file diff --git a/src/MaksIT.Persistence.Tests/ServiceCollectionExtensionsTests.cs b/src/MaksIT.Persistence.Tests/ServiceCollectionExtensionsTests.cs index 89675ae..aa565f2 100644 --- a/src/MaksIT.Persistence.Tests/ServiceCollectionExtensionsTests.cs +++ b/src/MaksIT.Persistence.Tests/ServiceCollectionExtensionsTests.cs @@ -31,19 +31,6 @@ public class ServiceCollectionExtensionsTests { Assert.Throws(() => services.RegisterLinq2Db(" ")); } - [Fact] - public void RegisterSchemaSync_RegistersService() { - var services = new ServiceCollection(); - services.AddLogging(); - IReadOnlyDictionary> desired = new Dictionary> { - ["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(); diff --git a/src/MaksIT.Persistence/Extensions/ServiceCollectionExtensions.cs b/src/MaksIT.Persistence/Extensions/ServiceCollectionExtensions.cs index 18e823b..09b2f48 100644 --- a/src/MaksIT.Persistence/Extensions/ServiceCollectionExtensions.cs +++ b/src/MaksIT.Persistence/Extensions/ServiceCollectionExtensions.cs @@ -71,26 +71,4 @@ public static class ServiceCollectionExtensions { .AddLogging(lb => lb.AddFluentMigratorConsole()); services.AddScoped(); } - - /// - /// Registers add-only schema sync. Desired schema maps stay in the product. - /// - public static void RegisterSchemaSync( - this IServiceCollection services, - string connectionString, - IReadOnlyDictionary> desiredSchema, - bool autoSync - ) { - if (string.IsNullOrWhiteSpace(connectionString)) - throw new ArgumentException("connectionString is required.", nameof(connectionString)); - ArgumentNullException.ThrowIfNull(desiredSchema); - - services.TryAddSingleton(); - services.AddSingleton(new SchemaSyncOptions { - ConnectionString = connectionString, - AutoSync = autoSync, - DesiredSchema = desiredSchema, - }); - services.AddScoped(); - } } \ No newline at end of file diff --git a/src/MaksIT.Persistence/Extensions/ServiceProviderExtensions.cs b/src/MaksIT.Persistence/Extensions/ServiceProviderExtensions.cs index d129daf..b37729e 100644 --- a/src/MaksIT.Persistence/Extensions/ServiceProviderExtensions.cs +++ b/src/MaksIT.Persistence/Extensions/ServiceProviderExtensions.cs @@ -5,11 +5,11 @@ using MaksIT.Persistence.Infrastructure; namespace MaksIT.Persistence.Extensions; /// -/// Host startup helpers for migrations and schema sync. +/// Host startup helpers for FluentMigrator. /// public static class ServiceProviderExtensions { /// - /// Runs then when registered. + /// Runs when registered. /// Call from Program.cs before RunAsync. /// public static async Task EnsureLinq2DbMigratedAsync( @@ -22,9 +22,5 @@ public static class ServiceProviderExtensions { var run = sp.GetService(); if (run is not null) await run.RunAsync(cancellationToken).ConfigureAwait(false); - - var schemaSync = sp.GetService(); - if (schemaSync is not null) - await schemaSync.SyncSchemaAsync(cancellationToken).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/MaksIT.Persistence/Infrastructure/IDatabaseStartupObserver.cs b/src/MaksIT.Persistence/Infrastructure/IDatabaseStartupObserver.cs index e76b0fe..6dd7db7 100644 --- a/src/MaksIT.Persistence/Infrastructure/IDatabaseStartupObserver.cs +++ b/src/MaksIT.Persistence/Infrastructure/IDatabaseStartupObserver.cs @@ -1,7 +1,7 @@ namespace MaksIT.Persistence.Infrastructure; /// -/// Optional hooks for reporting database startup phase timing (migrations, schema sync). +/// Optional hooks for reporting database startup phase timing (migrations). /// public interface IDatabaseStartupObserver { /// Called when a named startup phase begins. @@ -14,7 +14,7 @@ public interface IDatabaseStartupObserver { void OnPhaseFailed(string phase, TimeSpan elapsed, Exception exception); } -/// Well-known phase names reported during migrations and schema sync. +/// Well-known phase names reported during migrations. public static class DatabaseStartupPhases { /// Maintenance Postgres (database postgres) accepted connections. public const string PostgresMaintenanceReady = "postgres_maintenance_ready"; @@ -33,9 +33,6 @@ public static class DatabaseStartupPhases { /// Product schema verification completed. public const string SchemaVerified = "schema_verified"; - - /// Add-only schema sync completed. - public const string SchemaSync = "schema_sync"; } /// No-op observer used when the host does not register one. diff --git a/src/MaksIT.Persistence/Infrastructure/ISchemaSyncOptions.cs b/src/MaksIT.Persistence/Infrastructure/ISchemaSyncOptions.cs deleted file mode 100644 index 6468c99..0000000 --- a/src/MaksIT.Persistence/Infrastructure/ISchemaSyncOptions.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace MaksIT.Persistence.Infrastructure; - -/// Options for . -public interface ISchemaSyncOptions { - /// PostgreSQL connection string. - string ConnectionString { get; } - - /// When false, is a no-op. - bool AutoSync { get; } - - /// Desired table → columns map (product-owned). - IReadOnlyDictionary> DesiredSchema { get; } -} \ No newline at end of file diff --git a/src/MaksIT.Persistence/Infrastructure/ISchemaSyncService.cs b/src/MaksIT.Persistence/Infrastructure/ISchemaSyncService.cs deleted file mode 100644 index 7f2a8f3..0000000 --- a/src/MaksIT.Persistence/Infrastructure/ISchemaSyncService.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace MaksIT.Persistence.Infrastructure; - -/// -/// Syncs the database schema: add missing columns only (no DROP). -/// -public interface ISchemaSyncService { - /// Applies add-only DDL when auto-sync is enabled. - Task SyncSchemaAsync(CancellationToken cancellationToken = default); -} \ No newline at end of file diff --git a/src/MaksIT.Persistence/Infrastructure/SchemaColumn.cs b/src/MaksIT.Persistence/Infrastructure/SchemaColumn.cs deleted file mode 100644 index b576266..0000000 --- a/src/MaksIT.Persistence/Infrastructure/SchemaColumn.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace MaksIT.Persistence.Infrastructure; - -/// -/// Desired column for add-only schema sync. -/// -/// PostgreSQL column name (snake_case). -/// PostgreSQL type, e.g. uuid, text, boolean. -/// When false, generated ADD COLUMN includes NOT NULL (no default). -public sealed record SchemaColumn(string Name, string PgType, bool Nullable = false); \ No newline at end of file diff --git a/src/MaksIT.Persistence/Infrastructure/SchemaSyncDdlBuilder.cs b/src/MaksIT.Persistence/Infrastructure/SchemaSyncDdlBuilder.cs deleted file mode 100644 index 3d10aa7..0000000 --- a/src/MaksIT.Persistence/Infrastructure/SchemaSyncDdlBuilder.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace MaksIT.Persistence.Infrastructure; - -/// -/// Builds add-only ALTER TABLE statements. Does not CREATE or DROP tables. -/// -public static class SchemaSyncDdlBuilder { - /// - /// Returns ALTER TABLE ADD COLUMN IF NOT EXISTS for columns present in - /// and missing from . Skips tables that do not exist yet. - /// - public static IReadOnlyList BuildAddOnlyDdl( - IReadOnlyDictionary> desired, - IReadOnlyDictionary> current - ) { - List 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; - } - - /// Quotes a PostgreSQL identifier. - public static string Quote(string name) => $"\"{name.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; -} \ No newline at end of file diff --git a/src/MaksIT.Persistence/Infrastructure/SchemaSyncOptions.cs b/src/MaksIT.Persistence/Infrastructure/SchemaSyncOptions.cs deleted file mode 100644 index de9de64..0000000 --- a/src/MaksIT.Persistence/Infrastructure/SchemaSyncOptions.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace MaksIT.Persistence.Infrastructure; - -/// -public sealed class SchemaSyncOptions : ISchemaSyncOptions { - /// - public required string ConnectionString { get; init; } - - /// - public required bool AutoSync { get; init; } - - /// - public required IReadOnlyDictionary> DesiredSchema { get; init; } -} \ No newline at end of file diff --git a/src/MaksIT.Persistence/Infrastructure/SchemaSyncService.cs b/src/MaksIT.Persistence/Infrastructure/SchemaSyncService.cs deleted file mode 100644 index fbea512..0000000 --- a/src/MaksIT.Persistence/Infrastructure/SchemaSyncService.cs +++ /dev/null @@ -1,83 +0,0 @@ -using Microsoft.Extensions.Logging; -using Npgsql; - - -namespace MaksIT.Persistence.Infrastructure; - -/// -public sealed class SchemaSyncService( - ISchemaSyncOptions options, - ILogger logger, - IDatabaseStartupObserver startupObserver -) : ISchemaSyncService { - /// - 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>> GetCurrentSchemaAsync(CancellationToken ct) { - var result = new Dictionary>(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(StringComparer.OrdinalIgnoreCase); - - columns.Add(column); - } - - return result; - } -} \ No newline at end of file diff --git a/src/MaksIT.Persistence/MaksIT.Persistence.csproj b/src/MaksIT.Persistence/MaksIT.Persistence.csproj index 025ca6b..71313e2 100644 --- a/src/MaksIT.Persistence/MaksIT.Persistence.csproj +++ b/src/MaksIT.Persistence/MaksIT.Persistence.csproj @@ -7,11 +7,11 @@ true MaksIT.Persistence - 1.0.0 + 2.0.0 Maksym Sadovnychyy MAKS-IT MaksIT.Persistence - MaksIT.Persistence product-agnostic facades for linq2db PostgreSQL: connection factory, FluentMigrator startup, add-only schema sync, and Result guards. + MaksIT.Persistence product-agnostic facades for linq2db PostgreSQL: connection factory, FluentMigrator startup, and Result guards. dotnet;linq2db;postgresql;fluentmigrator; https://github.com/MAKS-IT-COM/maksit-persistence MIT diff --git a/utils/modules/ChangelogSupport.psm1 b/utils/modules/ChangelogSupport.psm1 index feb7afa..211c83a 100644 --- a/utils/modules/ChangelogSupport.psm1 +++ b/utils/modules/ChangelogSupport.psm1 @@ -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 diff --git a/utils/plugins/DotNet/DotNetReleaseVersion.psm1 b/utils/plugins/DotNet/DotNetReleaseVersion.psm1 index 3cb01fd..98a857f 100644 --- a/utils/plugins/DotNet/DotNetReleaseVersion.psm1 +++ b/utils/plugins/DotNet/DotNetReleaseVersion.psm1 @@ -7,9 +7,11 @@ .DESCRIPTION Dedicated version-loading plugin. Reads 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: not found in '$ProjectPath'." + if (-not [string]::IsNullOrWhiteSpace([string]$version)) { + return [string]$version } - return [string]$version + $version = Get-DirectoryBuildPropsVersionInternal -ProjectPath $ProjectPath + if (-not [string]::IsNullOrWhiteSpace([string]$version)) { + return [string]$version + } + + throw "DotNetReleaseVersion: 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' diff --git a/utils/plugins/Npm/NpmPublish.psm1 b/utils/plugins/Npm/NpmPublish.psm1 index db6b0ec..7357ffe 100644 --- a/utils/plugins/Npm/NpmPublish.psm1 +++ b/utils/plugins/Npm/NpmPublish.psm1 @@ -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'." diff --git a/utils/plugins/Npm/NpmReleaseVersion.psm1 b/utils/plugins/Npm/NpmReleaseVersion.psm1 index ad48e46..31f300a 100644 --- a/utils/plugins/Npm/NpmReleaseVersion.psm1 +++ b/utils/plugins/Npm/NpmReleaseVersion.psm1 @@ -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 diff --git a/utils/plugins/Platform/FileReleaseVersion.psm1 b/utils/plugins/Platform/FileReleaseVersion.psm1 index c7bb3bd..6095289 100644 --- a/utils/plugins/Platform/FileReleaseVersion.psm1 +++ b/utils/plugins/Platform/FileReleaseVersion.psm1 @@ -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) { diff --git a/utils/plugins/Platform/GitHub.psm1 b/utils/plugins/Platform/GitHub.psm1 index 61773e0..5528635 100644 --- a/utils/plugins/Platform/GitHub.psm1 +++ b/utils/plugins/Platform/GitHub.psm1 @@ -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) { diff --git a/utils/plugins/Platform/ReleasePublishGuard.psm1 b/utils/plugins/Platform/ReleasePublishGuard.psm1 index 43b6dfe..56153f6 100644 --- a/utils/plugins/Platform/ReleasePublishGuard.psm1 +++ b/utils/plugins/Platform/ReleasePublishGuard.psm1 @@ -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 }