diff --git a/.azurepipelines/package-version-policy.ps1 b/.azurepipelines/package-version-policy.ps1 index 03008c87f4..a5e432d9c8 100644 --- a/.azurepipelines/package-version-policy.ps1 +++ b/.azurepipelines/package-version-policy.ps1 @@ -36,6 +36,33 @@ function Get-PreviewPackageBuildNumber { return $node.InnerText.Trim() } +function Get-PreviewPackageOrderingExemptions { + <# + .SYNOPSIS + Reads the exact published versions (see preview-version.props) that + the preview ordering gate must not treat as blocking because no + numbered preview can ever outrank them and they cannot be removed + from the feed. + #> + param( + [string]$RepositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path + ) + + $propsPath = Join-Path $RepositoryRoot 'preview-version.props' + if (-not (Test-Path -LiteralPath $propsPath)) { + throw "Cannot resolve the preview ordering exemptions: '$propsPath' was not found." + } + + [xml]$props = Get-Content -LiteralPath $propsPath -Raw + $node = $props.SelectSingleNode('//*[local-name()="PreviewPackageOrderingExemptions"]') + if ($null -eq $node) { + return , @() + } + return , @($node.InnerText -split ';' | + ForEach-Object { $_.Trim() } | + Where-Object { $_ -ne '' }) +} + function ConvertTo-PreviewPackageVersion { param( [Parameter(Mandatory)][string]$Version, @@ -45,6 +72,13 @@ function ConvertTo-PreviewPackageVersion { if ($Version -match '-preview(?:[.+-]|$)') { return $Version } + if ($Version -cmatch '^[^-+]+-g[0-9a-f]+(\+.*)?$') { + # A commit id as the only prerelease identifier (a non-public build + # of a stable version.json). "-preview.g" would outrank every + # numbered preview under SemVer 2, so number it 0; version.targets + # applies the same rule. + return $Version -creplace '^([^-+]+)-(g[0-9a-f]+)', '$1-preview.0.$2' + } if ($Version.Contains('-')) { return $Version.Replace('-', '-preview.') } @@ -108,7 +142,9 @@ function Get-BlockingPublishedPreviewVersions { param( [Parameter(Mandatory)][string]$BaseVersion, [string[]]$PublishedVersions = @(), - [string]$PreviewPackageBuildNumber = (Get-PreviewPackageBuildNumber) + [string]$PreviewPackageBuildNumber = (Get-PreviewPackageBuildNumber), + # Exact versions that never block (see preview-version.props). + [string[]]$ExemptVersions = @() ) if ($PreviewPackageBuildNumber -notmatch '^\d+$') { @@ -134,6 +170,11 @@ function Get-BlockingPublishedPreviewVersions { if (-not $value.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { continue } + if ($ExemptVersions -contains $value) { + # An explicitly listed, unremovable version; PowerShell's + # -contains compares case-insensitively like NuGet versions. + continue + } if ($value.Length -eq $prefix.Length) { # Exactly "-preview": under SemVer 2 a prerelease with # fewer dot-separated identifiers always sorts below one that diff --git a/.azurepipelines/validate-preview-package-ordering.ps1 b/.azurepipelines/validate-preview-package-ordering.ps1 index 04e0324bc4..0f017375e6 100644 --- a/.azurepipelines/validate-preview-package-ordering.ps1 +++ b/.azurepipelines/validate-preview-package-ordering.ps1 @@ -130,6 +130,12 @@ $baseVersion = [string]$manifest.basePackageVersion $previewBuildNumber = Get-PreviewPackageBuildNumber $candidateVersion = ConvertTo-PreviewPackageVersion -Version $baseVersion ` -PreviewPackageBuildNumber $previewBuildNumber +$exemptVersions = Get-PreviewPackageOrderingExemptions +if ($exemptVersions.Count -gt 0) { + Write-Host ( + "Ignoring $($exemptVersions.Count) exempt published version(s) listed in " + + "preview-version.props: $($exemptVersions -join ', ').") +} $packageIds = @($manifest.archives | Where-Object { $_.type -eq 'package' -and (Test-PreviewPackageId -PackageId $_.id) } | @@ -171,7 +177,8 @@ foreach ($packageId in $packageIds) { $blocking = Get-BlockingPublishedPreviewVersions ` -BaseVersion $baseVersion ` -PublishedVersions $published[$feedName] ` - -PreviewPackageBuildNumber $previewBuildNumber + -PreviewPackageBuildNumber $previewBuildNumber ` + -ExemptVersions $exemptVersions if ($blocking.Count -gt 0) { $violations += [pscustomobject]@{ PackageId = $packageId diff --git a/docs/ReleaseProcess.md b/docs/ReleaseProcess.md index 25ea3df645..c9353ee7f7 100644 --- a/docs/ReleaseProcess.md +++ b/docs/ReleaseProcess.md @@ -703,7 +703,7 @@ or [Approved promotion](#approved-promotion). | Symptom | Cause and safe next action | | --- | --- | - | `validate-preview-package-ordering.ps1` lists package IDs whose published versions are not below the candidate's `-preview.N` | Ordinary `master` development published a higher `-preview.{height}` while this release was being prepared. Raise `PreviewPackageBuildNumber` in `preview-version.props` above every listed version, via a normal PR against the release branch, then build and validate a **new** candidate. Never lower a published version or delete packages to make room. | + | `validate-preview-package-ordering.ps1` lists package IDs whose published versions are not below the candidate's `-preview.N` | Ordinary `master` development published a higher `-preview.{height}` while this release was being prepared. Raise `PreviewPackageBuildNumber` in `preview-version.props` above every listed version, via a normal PR against the release branch, then build and validate a **new** candidate. Never lower a published version or delete packages to make room. If a listed version can never be outranked by any number - SemVer 2 sorts an alphanumeric identifier such as `-preview.g` above every `-preview.N` - add that exact version to `PreviewPackageOrderingExemptions` in `preview-version.props` instead, with a comment recording where it came from. | | The script throws while querying nuget.org or GitHub Packages (for example `401`/`403`, or any non-`404` error status) | The gate deliberately fails closed: it cannot prove ordering, so it refuses to let the release proceed. Do **not** bypass it. Confirm the workflow passes a token with `packages: read`/`packages: write` for the owner being queried, and that the feed is reachable, then re-run the same workflow. A genuinely unpublished package ID returns `404`, which the script treats as "nothing to compare" and allows. | 3. **If nuget.org already has some non-Debug packages, or GitHub Packages has diff --git a/preview-version.props b/preview-version.props index 36e7586a58..127640cf34 100644 --- a/preview-version.props +++ b/preview-version.props @@ -36,5 +36,21 @@ --> 200 + + 2.0.0-preview.gfd360271d9;2.0.0-preview.ge78c648295 diff --git a/tests/Opc.Ua.Tools.Tests/PackageVersionPolicyTests.cs b/tests/Opc.Ua.Tools.Tests/PackageVersionPolicyTests.cs index 6ac20d5c91..f0430b213e 100644 --- a/tests/Opc.Ua.Tools.Tests/PackageVersionPolicyTests.cs +++ b/tests/Opc.Ua.Tools.Tests/PackageVersionPolicyTests.cs @@ -139,6 +139,9 @@ namespace Opc.Ua.Tools.Tests [TestCase("2.0.0-preview.1.gabc123def0", "2.0.0-preview.1.gabc123def0", Description = "Already preview with commit id: unchanged")] [TestCase("2.0.1-preview.3+build5", "2.0.1-preview.3+build5", Description = "Already preview with build metadata: unchanged")] [TestCase("2.0.0-rc.1", "2.0.0-preview.rc.1", Description = "Other prerelease label: preview-prefixed")] + [TestCase("2.0.0-ge78c648295", "2.0.0-preview.0.ge78c648295", Description = "Commit id only: numbered 0")] + [TestCase("2.0.0-gabc123def0+b5", "2.0.0-preview.0.gabc123def0+b5", Description = "Commit id and metadata: numbered 0")] + [TestCase("2.0.0-gamma.1", "2.0.0-preview.gamma.1", Description = "A label that merely starts with 'g' is not a commit id")] public async Task ConvertToPreviewPackageVersionIsIdempotentForExistingPrereleaseAsync( string input, string expected) @@ -171,6 +174,117 @@ namespace Opc.Ua.Tools.Tests Assert.That(result.GetProperty("actual").GetString(), Is.EqualTo(expected)); } + [Test] + public async Task VersionTargetsAppliesTheSamePreviewVersionRulesAsConvertToPreviewPackageVersionAsync() + { + // ApplyPreviewPackageVersion in version.targets is what actually + // stamps package versions; ConvertTo-PreviewPackageVersion is an + // independent re-implementation the validators use. Evaluate the + // real target through MSBuild so the two cannot drift apart. + (string Input, string Expected)[] cases = + [ + ("2.0.0", "2.0.0-preview.42"), + ("2.0.0+gabc123def0", "2.0.0-preview.42+gabc123def0"), + ("2.0.0-preview.6", "2.0.0-preview.6"), + ("2.0.1-preview.5.gfa06c66dbb", "2.0.1-preview.5.gfa06c66dbb"), + ("2.0.0-rc.1", "2.0.0-preview.rc.1"), + ("2.0.0-gamma.1", "2.0.0-preview.gamma.1"), + ("2.0.0-ge78c648295", "2.0.0-preview.0.ge78c648295"), + ("2.0.0-gabc123def0+b5", "2.0.0-preview.0.gabc123def0+b5"), + ]; + + string fixtureDirectory = Path.Combine( + TestContext.CurrentContext.WorkDirectory, + "package-version-policy-fixtures", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(fixtureDirectory); + string projectPath = Path.Combine(fixtureDirectory, "versions.proj"); + string resultPath = Path.Combine(fixtureDirectory, "versions.txt"); + string versionTargetsPath = Path.Combine(FindRepositoryRoot(), "version.targets"); + string items = string.Concat(cases.Select(c => $"")); + + // A plain (non-SDK) project imports no Directory.Build.* files, so + // only version.targets contributes. NBGV_PublicRelease skips the + // GetBuildVersion dependency, as on a release branch build. + await File.WriteAllTextAsync( + projectPath, + $$""" + + + OPCFoundation.NetStandard + OPCFoundation.NetStandard.Opc.Ua.AI + true + True + 42 + $(InputVersion) + + {{items}} + + + + + + + + + + + + + + """).ConfigureAwait(false); + + try + { + using var process = new Process(); + process.StartInfo.FileName = "dotnet"; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.ArgumentList.Add("msbuild"); + process.StartInfo.ArgumentList.Add(projectPath); + process.StartInfo.ArgumentList.Add("-nologo"); + process.StartInfo.ArgumentList.Add("-nodeReuse:false"); + + Assert.That(process.Start(), Is.True); + Task standardOutput = process.StandardOutput.ReadToEndAsync(); + Task standardError = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + await process.WaitForExitAsync(timeout.Token).ConfigureAwait(false); + string output = await standardOutput.ConfigureAwait(false); + string error = await standardError.ConfigureAwait(false); + Assert.That(process.ExitCode, Is.Zero, $"dotnet msbuild failed:\n{output}\n{error}"); + + Dictionary actual = (await File.ReadAllLinesAsync(resultPath).ConfigureAwait(false)) + .Select(line => line.Split('=', 2)) + .ToDictionary(parts => parts[0], parts => parts[1], StringComparer.Ordinal); + + JsonElement policy = await RunPolicyScriptAsync( + $$""" + . '{{PolicyScriptPath}}' + $inputs = @({{string.Join(",", cases.Select(c => $"'{c.Input}'"))}}) + $results = foreach ($input in $inputs) { + ConvertTo-PreviewPackageVersion -Version $input -PreviewPackageBuildNumber '42' + } + $results | ConvertTo-Json -AsArray + """).ConfigureAwait(false); + + Assert.Multiple(() => + { + for (int i = 0; i < cases.Length; i++) + { + (string input, string expected) = cases[i]; + Assert.That(actual.TryGetValue(input, out string? mapped), Is.True, $"version.targets produced no result for '{input}'."); + Assert.That(mapped, Is.EqualTo(expected), $"version.targets mapped '{input}' incorrectly."); + Assert.That(policy[i].GetString(), Is.EqualTo(expected), $"ConvertTo-PreviewPackageVersion mapped '{input}' incorrectly."); + } + }); + } + finally + { + Directory.Delete(fixtureDirectory, recursive: true); + } + } + [Test] public async Task GetExpectedPackageVersionAppliesPolicyOnlyToPreviewFamiliesAsync() { @@ -613,6 +727,42 @@ namespace Opc.Ua.Tools.Tests "not silently assumed to be lower."); } + [Test] + public async Task GetBlockingPublishedPreviewVersionsSkipsOnlyExactExemptVersionsAsync() + { + JsonElement result = await RunBlockingPreviewVersionsAsync( + baseVersion: "2.0.0", + previewPackageBuildNumber: "200", + publishedVersions: + [ + "2.0.0-preview.gfd360271d9", + // Exemptions match case-insensitively, like NuGet versions. + "2.0.0-PREVIEW.GE78C648295", + // Not listed: a commit-id label still blocks. + "2.0.0-preview.g0123456789", + "2.0.0-preview.201", + ], + exemptVersions: ["2.0.0-preview.gfd360271d9", "2.0.0-preview.ge78c648295"]).ConfigureAwait(false); + + string[] blocking = [.. result.EnumerateArray().Select(e => e.GetString()!)]; + string[] expected = ["2.0.0-preview.201", "2.0.0-preview.g0123456789"]; + Assert.That(blocking, Is.EquivalentTo(expected)); + } + + [Test] + public async Task GetPreviewPackageOrderingExemptionsReadsTheCommittedListAsync() + { + JsonElement result = await RunPolicyScriptAsync( + $$""" + . '{{PolicyScriptPath}}' + ConvertTo-Json -InputObject (Get-PreviewPackageOrderingExemptions) -Depth 3 + """).ConfigureAwait(false); + + string[] exemptions = [.. result.EnumerateArray().Select(e => e.GetString()!)]; + Assert.That(exemptions, Has.All.Matches(v => v.Length > 0 && v.Trim() == v)); + Assert.That(exemptions, Has.All.Contains("-preview.")); + } + [Test] public async Task GetBlockingPublishedPreviewVersionsRejectsANonStableBaseVersionAsync() { @@ -1124,16 +1274,19 @@ namespace Opc.Ua.Tools.Tests private static Task RunBlockingPreviewVersionsAsync( string baseVersion, string previewPackageBuildNumber, - string[] publishedVersions) + string[] publishedVersions, + string[]? exemptVersions = null) { string published = string.Join(",", publishedVersions.Select(v => $"'{v}'")); + string exempt = string.Join(",", (exemptVersions ?? []).Select(v => $"'{v}'")); return RunPolicyScriptAsync( $$""" . '{{PolicyScriptPath}}' $blocking = Get-BlockingPublishedPreviewVersions ` -BaseVersion '{{baseVersion}}' ` -PublishedVersions @({{published}}) ` - -PreviewPackageBuildNumber '{{previewPackageBuildNumber}}' + -PreviewPackageBuildNumber '{{previewPackageBuildNumber}}' ` + -ExemptVersions @({{exempt}}) ConvertTo-Json -InputObject $blocking -Depth 3 """); } diff --git a/version.targets b/version.targets index 992f34bfb1..8d35ed367f 100644 --- a/version.targets +++ b/version.targets @@ -65,6 +65,19 @@ to do. --> <_PreviewPackageVersion Condition="$([System.String]::Copy('$(_PreviewSourceVersion)').Contains('-preview'))">$(_PreviewSourceVersion) + + <_PreviewPackageVersion + Condition="'$(_PreviewPackageVersion)' == '' AND $([System.Text.RegularExpressions.Regex]::IsMatch('$(_PreviewSourceVersion)', '^[^-+]+-g[0-9a-f]+(\+.*)?$'))">$([System.Text.RegularExpressions.Regex]::Replace('$(_PreviewSourceVersion)', '^([^-+]+)-(g[0-9a-f]+)', '$1-preview.0.$2'))