Exempt unremovable preview.g<commit> versions from the preview ordering gate (#4691)

## Problem

The stable 2.0.0 `NuGet Publish` run on `release/2.0` ([run
37567251061](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/37567251061))
fails in **Verify preview package ordering**.

Two master runs on 2026-10-06
([37461428995](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/37461428995),
[37462705289](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/37462705289))
pushed `2.0.0-preview.gfd360271d9` and `2.0.0-preview.ge78c648295` of
every preview-family package to GitHub Packages. At that point master
briefly had the stable root version `2.0.0`, so Nerdbank.GitVersioning
appended the commit id. SemVer 2 ranks an alphanumeric identifier above
every numeric one, so **no** `PreviewPackageBuildNumber` can outrank
these versions. Deleting them would need org package admin rights, and
docs/ReleaseProcess.md forbids deleting packages to make room anyway.

## Change

- `preview-version.props`: adds a new `PreviewPackageOrderingExemptions`
list (`;`-separated exact versions) with the two versions above and a
comment recording where they came from.
- `package-version-policy.ps1`: adds
`Get-PreviewPackageOrderingExemptions`.
`Get-BlockingPublishedPreviewVersions` takes an optional
`-ExemptVersions` and skips exact (case-insensitive) matches only. Every
other undecidable or higher version still blocks, so the gate still
fails closed.
- `validate-preview-package-ordering.ps1`: passes the list in and logs
what it ignores. `nuget-publish.yml` and `release.yml` both use it.
- `docs/ReleaseProcess.md`: the troubleshooting row says when to use an
exemption.
- Tests: exemptions are exact-match only; the committed list parses. All
89 `PackageVersionPolicyTests` pass on net10.0.

## Trade-off

On GitHub Packages only, the released `2.0.0-preview.200` packages still
sort below the two hash builds. nuget.org never had them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
romanett
2026-10-07 08:27:41 +02:00
committed by GitHub
6 changed files with 235 additions and 5 deletions
+42 -1
View File
@@ -36,6 +36,33 @@ function Get-PreviewPackageBuildNumber {
return $node.InnerText.Trim() 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 { function ConvertTo-PreviewPackageVersion {
param( param(
[Parameter(Mandatory)][string]$Version, [Parameter(Mandatory)][string]$Version,
@@ -45,6 +72,13 @@ function ConvertTo-PreviewPackageVersion {
if ($Version -match '-preview(?:[.+-]|$)') { if ($Version -match '-preview(?:[.+-]|$)') {
return $Version 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<commit>" 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('-')) { if ($Version.Contains('-')) {
return $Version.Replace('-', '-preview.') return $Version.Replace('-', '-preview.')
} }
@@ -108,7 +142,9 @@ function Get-BlockingPublishedPreviewVersions {
param( param(
[Parameter(Mandatory)][string]$BaseVersion, [Parameter(Mandatory)][string]$BaseVersion,
[string[]]$PublishedVersions = @(), [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+$') { if ($PreviewPackageBuildNumber -notmatch '^\d+$') {
@@ -134,6 +170,11 @@ function Get-BlockingPublishedPreviewVersions {
if (-not $value.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { if (-not $value.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) {
continue 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) { if ($value.Length -eq $prefix.Length) {
# Exactly "<base>-preview": under SemVer 2 a prerelease with # Exactly "<base>-preview": under SemVer 2 a prerelease with
# fewer dot-separated identifiers always sorts below one that # fewer dot-separated identifiers always sorts below one that
@@ -130,6 +130,12 @@ $baseVersion = [string]$manifest.basePackageVersion
$previewBuildNumber = Get-PreviewPackageBuildNumber $previewBuildNumber = Get-PreviewPackageBuildNumber
$candidateVersion = ConvertTo-PreviewPackageVersion -Version $baseVersion ` $candidateVersion = ConvertTo-PreviewPackageVersion -Version $baseVersion `
-PreviewPackageBuildNumber $previewBuildNumber -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 | $packageIds = @($manifest.archives |
Where-Object { $_.type -eq 'package' -and (Test-PreviewPackageId -PackageId $_.id) } | Where-Object { $_.type -eq 'package' -and (Test-PreviewPackageId -PackageId $_.id) } |
@@ -171,7 +177,8 @@ foreach ($packageId in $packageIds) {
$blocking = Get-BlockingPublishedPreviewVersions ` $blocking = Get-BlockingPublishedPreviewVersions `
-BaseVersion $baseVersion ` -BaseVersion $baseVersion `
-PublishedVersions $published[$feedName] ` -PublishedVersions $published[$feedName] `
-PreviewPackageBuildNumber $previewBuildNumber -PreviewPackageBuildNumber $previewBuildNumber `
-ExemptVersions $exemptVersions
if ($blocking.Count -gt 0) { if ($blocking.Count -gt 0) {
$violations += [pscustomobject]@{ $violations += [pscustomobject]@{
PackageId = $packageId PackageId = $packageId
+1 -1
View File
@@ -703,7 +703,7 @@ or [Approved promotion](#approved-promotion).
| Symptom | Cause and safe next action | | 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 `<base>-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 `<base>-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 `<base>-preview.g<commit>` 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. | | 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 3. **If nuget.org already has some non-Debug packages, or GitHub Packages has
+16
View File
@@ -36,5 +36,21 @@
--> -->
<PropertyGroup> <PropertyGroup>
<PreviewPackageBuildNumber Condition="'$(PreviewPackageBuildNumber)' == ''">200</PreviewPackageBuildNumber> <PreviewPackageBuildNumber Condition="'$(PreviewPackageBuildNumber)' == ''">200</PreviewPackageBuildNumber>
<!--
Exact, already-published versions that the preview ordering gate
(.azurepipelines/validate-preview-package-ordering.ps1) must not treat
as blocking, separated by ";". Only for versions that no
PreviewPackageBuildNumber can ever outrank and that cannot be removed
from the feed: SemVer 2 ranks an alphanumeric identifier such as
"g<commit>" above every numeric one, so "<base>-preview.g<commit>"
sorts above every "<base>-preview.N".
2.0.0-preview.gfd360271d9 and 2.0.0-preview.ge78c648295 were pushed to
GitHub Packages (never nuget.org) by master CI runs 37461428995 and
37462705289 on 2026-10-06, while master briefly carried the stable
root version 2.0.0 and Nerdbank.GitVersioning appended the commit id.
They stay above the released 2.0.0-preview.N on that feed only.
-->
<PreviewPackageOrderingExemptions>2.0.0-preview.gfd360271d9;2.0.0-preview.ge78c648295</PreviewPackageOrderingExemptions>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
@@ -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.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.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-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( public async Task ConvertToPreviewPackageVersionIsIdempotentForExistingPrereleaseAsync(
string input, string input,
string expected) string expected)
@@ -171,6 +174,117 @@ namespace Opc.Ua.Tools.Tests
Assert.That(result.GetProperty("actual").GetString(), Is.EqualTo(expected)); 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 => $"<Case Include=\"{c.Input}\" />"));
// 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,
$$"""
<Project DefaultTargets="Run">
<PropertyGroup>
<PackagePrefix>OPCFoundation.NetStandard</PackagePrefix>
<PackageId>OPCFoundation.NetStandard.Opc.Ua.AI</PackageId>
<IsPackable>true</IsPackable>
<NBGV_PublicRelease>True</NBGV_PublicRelease>
<PreviewPackageBuildNumber>42</PreviewPackageBuildNumber>
<PackageVersion>$(InputVersion)</PackageVersion>
</PropertyGroup>
<ItemGroup>{{items}}</ItemGroup>
<Import Project="{{versionTargetsPath}}" />
<Target Name="Map" DependsOnTargets="ApplyPreviewPackageVersion" Returns="@(Mapped)">
<ItemGroup>
<Mapped Include="$(InputVersion)" Actual="$(PackageVersion)" />
</ItemGroup>
</Target>
<Target Name="Run">
<MSBuild Projects="$(MSBuildProjectFullPath)" Targets="Map" Properties="InputVersion=%(Case.Identity)">
<Output TaskParameter="TargetOutputs" ItemName="Result" />
</MSBuild>
<WriteLinesToFile File="{{resultPath}}" Lines="@(Result->'%(Identity)=%(Actual)')" Overwrite="true" />
</Target>
</Project>
""").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<string> standardOutput = process.StandardOutput.ReadToEndAsync();
Task<string> 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<string, string> 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] [Test]
public async Task GetExpectedPackageVersionAppliesPolicyOnlyToPreviewFamiliesAsync() public async Task GetExpectedPackageVersionAppliesPolicyOnlyToPreviewFamiliesAsync()
{ {
@@ -613,6 +727,42 @@ namespace Opc.Ua.Tools.Tests
"not silently assumed to be lower."); "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<string>(v => v.Length > 0 && v.Trim() == v));
Assert.That(exemptions, Has.All.Contains("-preview."));
}
[Test] [Test]
public async Task GetBlockingPublishedPreviewVersionsRejectsANonStableBaseVersionAsync() public async Task GetBlockingPublishedPreviewVersionsRejectsANonStableBaseVersionAsync()
{ {
@@ -1124,16 +1274,19 @@ namespace Opc.Ua.Tools.Tests
private static Task<JsonElement> RunBlockingPreviewVersionsAsync( private static Task<JsonElement> RunBlockingPreviewVersionsAsync(
string baseVersion, string baseVersion,
string previewPackageBuildNumber, string previewPackageBuildNumber,
string[] publishedVersions) string[] publishedVersions,
string[]? exemptVersions = null)
{ {
string published = string.Join(",", publishedVersions.Select(v => $"'{v}'")); string published = string.Join(",", publishedVersions.Select(v => $"'{v}'"));
string exempt = string.Join(",", (exemptVersions ?? []).Select(v => $"'{v}'"));
return RunPolicyScriptAsync( return RunPolicyScriptAsync(
$$""" $$"""
. '{{PolicyScriptPath}}' . '{{PolicyScriptPath}}'
$blocking = Get-BlockingPublishedPreviewVersions ` $blocking = Get-BlockingPublishedPreviewVersions `
-BaseVersion '{{baseVersion}}' ` -BaseVersion '{{baseVersion}}' `
-PublishedVersions @({{published}}) ` -PublishedVersions @({{published}}) `
-PreviewPackageBuildNumber '{{previewPackageBuildNumber}}' -PreviewPackageBuildNumber '{{previewPackageBuildNumber}}' `
-ExemptVersions @({{exempt}})
ConvertTo-Json -InputObject $blocking -Depth 3 ConvertTo-Json -InputObject $blocking -Depth 3
"""); """);
} }
+13
View File
@@ -65,6 +65,19 @@
to do. --> to do. -->
<_PreviewPackageVersion <_PreviewPackageVersion
Condition="$([System.String]::Copy('$(_PreviewSourceVersion)').Contains('-preview'))">$(_PreviewSourceVersion)</_PreviewPackageVersion> Condition="$([System.String]::Copy('$(_PreviewSourceVersion)').Contains('-preview'))">$(_PreviewSourceVersion)</_PreviewPackageVersion>
<!-- Case 2a: the root version's only prerelease identifier is a commit
id (e.g. "2.0.0-gabcdef0123"). Nerdbank.GitVersioning produces
this for a stable version.json built from a non-public-release
ref, such as master briefly carrying "2.0.0". Case 2 would turn it
into "2.0.0-preview.gabcdef0123", and SemVer 2 ranks the
alphanumeric "g..." identifier above every numeric one, so it
would outrank every "-preview.N" ever released from that base -
permanently, because published versions are immutable. Number it
0 instead so it sorts below any committed
PreviewPackageBuildNumber. ConvertTo-PreviewPackageVersion in
.azurepipelines/package-version-policy.ps1 applies the same rule. -->
<_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'))</_PreviewPackageVersion>
<!-- Case 2: the root version carries some other prerelease label <!-- Case 2: the root version carries some other prerelease label
(for example a hypothetical "-rc.1"). Preserve that label but (for example a hypothetical "-rc.1"). Preserve that label but
mark the selected families as preview under it too. --> mark the selected families as preview under it too. -->