mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-16 21:02:51 +08:00
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:
@@ -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<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('-')) {
|
||||
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 "<base>-preview": under SemVer 2 a prerelease with
|
||||
# fewer dot-separated identifiers always sorts below one that
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<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. |
|
||||
|
||||
3. **If nuget.org already has some non-Debug packages, or GitHub Packages has
|
||||
|
||||
@@ -36,5 +36,21 @@
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<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>
|
||||
</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.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 => $"<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]
|
||||
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<string>(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<JsonElement> 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
|
||||
""");
|
||||
}
|
||||
|
||||
@@ -65,6 +65,19 @@
|
||||
to do. -->
|
||||
<_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
|
||||
(for example a hypothetical "-rc.1"). Preserve that label but
|
||||
mark the selected families as preview under it too. -->
|
||||
|
||||
Reference in New Issue
Block a user