Files
UA-.NETStandard/.azurepipelines/package-version-policy.ps1
Marc Schier 0374e19121 Add MachineTool, WMTP, GMS and CranesHoists companions (#4765)
# Description

_Describe the changes here to communicate to the maintainers why they
should accept this pull request. By default - this will become the
Commit message after merging and thus define history._

Add provider-backed MachineTool, bundled WMTP, GMS and CranesHoists
companions using the existing Machinery/MachineVision fluent, DI and
typed-client approach. **The PR is open/non-draft by user choice.
Current head `ecc4a7d6ca3e8004cf28ab54da605da302d026bf` is mergeable and
has 108 successful checks, six intentionally skipped checks, zero
failures and zero pending checks. All five primary workflows completed
successfully at that exact head.**

| Family | Released specification/model | Packaging |
| --- | --- | --- |
| MachineTool | OPC 40501-1, 1.02.0 | Model, Server, Client and MCP;
normal root versions |
| WMTP | OPC 40505, 1.0.0 | Bundled in MachineTool; distinct CLR/wire
namespace and independent WMTP-only hosting |
| GMS | OPC 40210 reference 1.0.1, normative model 1.0.0 | Model,
Server, Client and MCP; normal root versions |
| CranesHoists | OPC 40020-1, 1.00 | Model, Server, Client and MCP; all
four plus Debug variants forced preview |

There are **12 new packable projects**. No separate WMTP
package/profile/guide, root version bump, tag, release or package
publication is included. Mandatory instance structure is the default;
selected optional features require explicit providers. The
implementation reuses Machinery results/jobs/files/ProcessValues and
Robotics topology, including retained MachineTool Production, defined
GMS jobs/parts, modern ISA95/Machinery jobs, shared-owner hosting and
isolated instances alongside MachineVision.

Runtime and integration fixes preserve raw quality/timestamps, coherent
acquisition, namespace ownership, accepted-configuration state,
borrowed-resource lifetime, session/node/file cleanup, terminal-value
publication, event propagation and endpoint-aware capability
publication. Firmware/licence transfer means provider acceptance, not
installation/activation. Cancellation does not silently abort another
caller's operation. The later upstream MCP expansion is integrated
without losing either family set or its default-off privileged/live
execution policy.

### Conformance and scope

Canonical model inputs are pinned to UA-Nodeset revision
`96a08945425d0295edb3607d2a38f6841ef44046`.

**GMS upstream exception:** [Annex
A.1](https://reference.opcfoundation.org/specs/OPC-40210/annex-a) makes
the released NodeSet normative, but §12.3.2.7 requires undefined
`GMS_ProductionStatisticsType`. Inherited statistics and defined
jobs/parts are implemented without inventing a standard type/NodeId; the
complete GMS Production facet is withheld.

The requirement ledger records **102 positively evidenced CU rows, 96
directly negative-tested rows and six explicit negative-evidence
limitations**. All 648 test-class/name/TRX links were independently
resolved to passing cases. The six limitations concern Stacklight,
retained legacy PowerOnDuration, Machinery PowerOnDuration, WorkingUnit,
Obligation and MaintenanceMode. They are not claims that every
invalid-input or hardware-control behavior was tested. The undefined GMS
requirement is outside the 102 defined CUs. No external certification
claim is made.

Retained baseline complete-assembly line coverage, with generated code
retained and no source exclusions:

| Family | Model | Server | Client |
| --- | ---: | ---: | ---: |
| MachineTool/WMTP | 83.80% | 91.98% | 84.74% |
| GMS | 95.51% | 90.63% | 88.38% |
| CranesHoists | 92.76% | 94.45% | 92.24% |

These nine measurements predate the later focused review fixes and
merges; they are not relabelled as new `ecc4a7d6c` percentages. The
current head's separate CI code-coverage check succeeded. No new
exclusions or threshold relaxations were introduced.

## Related Issues

- Fixes
[#4764](https://github.com/OPCFoundation/UA-.NETStandard/issues/4764),
the implementation/design and acceptance tracker.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [ ] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.
2026-10-15 15:15:46 +02:00

468 lines
19 KiB
PowerShell

function Test-PreviewPackageId {
param([Parameter(Mandatory)][string]$PackageId)
$baseId = $PackageId -replace '\.Debug$', ''
return $baseId -match '^OPCFoundation\.NetStandard\.Opc\.Ua\.(XRegistry|WotCon|Vision\.OpenUsd|CranesHoists|Robotics|Redundancy|Positioning|OpenUsd|AI)(\.|$)' -or
$baseId -in @(
'OPCFoundation.NetStandard.Opc.Ua.Mcp.Robotics',
'OPCFoundation.NetStandard.Opc.Ua.Mcp.CranesHoists',
'OPCFoundation.NetStandard.Opc.Ua.OpenUsd.Connector',
'OPCFoundation.NetStandard.Opc.Ua.OpenUsd.Connector.Viewer')
}
function Get-PreviewPackageBuildNumber {
<#
.SYNOPSIS
Reads the single source of truth for the numbered "-preview.N"
suffix (see preview-version.props) that version.targets applies to
the preview-only package families whenever the root package version
is an exact stable release with no prerelease label of its own to
reuse.
#>
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 package build number: '$propsPath' was not found."
}
[xml]$props = Get-Content -LiteralPath $propsPath -Raw
$node = $props.SelectSingleNode('//*[local-name()="PreviewPackageBuildNumber"]')
if ($null -eq $node -or [string]::IsNullOrWhiteSpace($node.InnerText)) {
throw "'$propsPath' does not define a PreviewPackageBuildNumber value."
}
return $node.InnerText.Trim()
}
function ConvertTo-PreviewPackageVersion {
param(
[Parameter(Mandatory)][string]$Version,
[string]$PreviewPackageBuildNumber = (Get-PreviewPackageBuildNumber)
)
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.')
}
if ($Version.Contains('+')) {
return $Version.Replace('+', "-preview.$PreviewPackageBuildNumber+")
}
return "$Version-preview.$PreviewPackageBuildNumber"
}
function Get-ExpectedPackageVersion {
param(
[Parameter(Mandatory)][string]$PackageId,
[Parameter(Mandatory)][string]$BaseVersion
)
if (Test-PreviewPackageId $PackageId) {
return ConvertTo-PreviewPackageVersion -Version $BaseVersion
}
return $BaseVersion
}
function Get-BlockingPublishedPreviewVersions {
<#
.SYNOPSIS
Returns the already-published versions that a synthesized
"<BaseVersion>-preview.N" candidate does not sort strictly above.
.DESCRIPTION
Whenever the root version is an exact stable release it carries no
git-height placeholder to reuse, so version.targets pins the
preview-only families to the explicitly committed number in
preview-version.props instead. That number is a manual release
decision, so it goes stale on its own: ordinary development keeps
publishing "<BaseVersion>-preview.<height>" packages with a growing
height, and once the height passes the committed number the released
package would sort *below* a CI package under SemVer 2 precedence.
NuGet versions are immutable, so that mistake cannot be corrected
after the fact - consumers tracking the latest prerelease would
simply never see the release.
This predicate is deliberately pure: callers supply the published
version list they fetched from a feed, which keeps it deterministic
and offline-testable. It fails closed - a published version sharing
the same "<BaseVersion>-preview" base whose ordering cannot be
decided numerically is reported as blocking and requires an explicit
decision rather than being assumed lower.
One published version is deliberately *not* blocking: the candidate's
own exact "<BaseVersion>-preview.<N>" identity. NuGet versions are
immutable, so a promotion that fails part-way through leaves some of
the candidate's own packages on the feed, and the documented recovery
is to re-run the same candidate (see docs/ReleaseProcess.md). Treating
that as a violation would make the recovery path unreachable. It is
safe because release.yml proves the already-published bytes really are
this candidate's, through assert-published-packages-match.ps1, before
it pushes anything - a foreign package that happens to occupy the
candidate's version fails there instead. Any version that merely
*starts* with the candidate's number carries extra prerelease
identifiers, sorts strictly above it under SemVer 2, and still blocks.
#>
param(
[Parameter(Mandatory)][string]$BaseVersion,
[string[]]$PublishedVersions = @(),
[string]$PreviewPackageBuildNumber = (Get-PreviewPackageBuildNumber)
)
if ($PreviewPackageBuildNumber -notmatch '^\d+$') {
throw "The preview package build number '$PreviewPackageBuildNumber' is not a non-negative integer."
}
if (-not (Test-StablePackageVersion -Version $BaseVersion)) {
throw (
"Preview ordering is only defined for an exact stable base version such as '2.0.0'; " +
"got '$BaseVersion'.")
}
$candidate = [int]$PreviewPackageBuildNumber
$prefix = "$BaseVersion-preview"
$numbered = [regex]::new(
'^' + [regex]::Escape($prefix) + '\.(?<number>\d+)(?<rest>[.+\-].*)?$',
[System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
$blocking = foreach ($published in $PublishedVersions) {
if ([string]::IsNullOrWhiteSpace($published)) {
continue
}
$value = $published.Trim()
if (-not $value.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) {
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
# shares its prefix and adds more, so this can never block.
continue
}
$match = $numbered.Match($value)
if (-not $match.Success) {
# For example a legacy "<base>-preview20240131" label: the
# ordering cannot be decided numerically, so surface it.
$value
continue
}
$number = [int]$match.Groups['number'].Value
if ($number -gt $candidate) {
$value
continue
}
if ($number -lt $candidate) {
continue
}
# Same number as the candidate. A bare "<base>-preview.<N>" - or one
# carrying only "+build" metadata, which SemVer 2 and NuGet both
# ignore for precedence - is the candidate's own identity and is
# allowed so a partially completed promotion can be re-run. Extra
# prerelease identifiers ("-preview.<N>.gabc123") sort above it.
$rest = $match.Groups['rest'].Value
if ($rest -ne '' -and -not $rest.StartsWith('+')) {
$value
}
}
return , @($blocking | Sort-Object -Unique)
}
function Get-NuGetPackageContentDigest {
<#
.SYNOPSIS
Returns a stable SHA-256 digest over a .nupkg's *content*, ignoring
the package signature.
.DESCRIPTION
A raw file hash cannot be used to compare a local candidate with the
same version already sitting on a feed: nuget.org repository-signs
every package at ingestion, so the bytes it serves are never
byte-identical to the bytes that were pushed even when nothing else
changed. Hashing each entry except the signature part compares what
actually has to match - the assemblies, the nuspec and every other
file - while tolerating the signature the feed legitimately added.
The digest is computed over a canonical "<entry>\t<sha256>" listing
sorted by ordinal entry name, so zip ordering differences do not
change the result.
#>
param([Parameter(Mandatory)][string]$Path)
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [System.IO.Compression.ZipFile]::OpenRead($Path)
try {
$entryHash = [System.Security.Cryptography.SHA256]::Create()
try {
$lines = [System.Collections.Generic.List[string]]::new()
foreach ($entry in $archive.Entries) {
# ".signature.p7s" is the only part a feed rewrites.
if ($entry.FullName -ceq '.signature.p7s' -or $entry.FullName.EndsWith('/')) {
continue
}
$stream = $entry.Open()
try {
$hash = $entryHash.ComputeHash($stream)
}
finally {
$stream.Dispose()
}
$lines.Add(
"$($entry.FullName)`t" +
[System.BitConverter]::ToString($hash).Replace('-', ''))
}
}
finally {
$entryHash.Dispose()
}
}
finally {
$archive.Dispose()
}
if ($lines.Count -eq 0) {
throw "'$Path' contains no package content to compare."
}
# Ordinal, not culture-aware: the digest has to be reproducible on every
# runner locale.
$lines.Sort([System.StringComparer]::Ordinal)
$listingHash = [System.Security.Cryptography.SHA256]::Create()
try {
$bytes = [System.Text.Encoding]::UTF8.GetBytes(($lines -join "`n"))
return [System.BitConverter]::ToString($listingHash.ComputeHash($bytes)).
Replace('-', '').ToLowerInvariant()
}
finally {
$listingHash.Dispose()
}
}
function Invoke-FeedRequest {
<#
.SYNOPSIS
Invoke-WebRequest that retries transient failures only and returns
the final response for the caller to judge by status code.
.DESCRIPTION
Invoke-WebRequest's own -MaximumRetryCount retries every status from
400 to 599, so each 404 - the normal "this id/version is not
published" answer the release gates ask for once per package - cost
three pointless retries and ~15 s. Across a full package set on two
feeds that turned a read-only check into an hour-long step.
Only a network failure, 408, 429 or 5xx is retried here. Every other
status is returned straight away; after the last retry the final
response is returned (or the network error rethrown), so callers keep
failing closed on anything they do not recognise.
#>
param(
[Parameter(Mandatory)][string]$Uri,
[hashtable]$Headers = @{},
[string]$OutFile,
[ValidateRange(0, 10)][int]$MaximumRetryCount = 3,
[ValidateRange(0, 60)][int]$RetryIntervalSec = 5
)
$parameters = @{
Uri = $Uri
Headers = $Headers
SkipHttpErrorCheck = $true
}
if ($OutFile) {
$parameters['OutFile'] = $OutFile
$parameters['PassThru'] = $true
}
for ($attempt = 0; ; $attempt++) {
try {
$response = Invoke-WebRequest @parameters
}
catch {
# -SkipHttpErrorCheck means only failures without an HTTP
# status (DNS, connection reset, timeout) reach this block.
if ($attempt -ge $MaximumRetryCount) {
throw
}
Start-Sleep -Seconds $RetryIntervalSec
continue
}
$status = [int]$response.StatusCode
$transient = $status -eq 408 -or $status -eq 429 -or $status -ge 500
if (-not $transient -or $attempt -ge $MaximumRetryCount) {
return $response
}
Start-Sleep -Seconds $RetryIntervalSec
}
}
function Get-PreviousReleaseTag {
<#
.SYNOPSIS
Returns the release tag GitHub should generate release notes from:
the greatest semantic version of the same major that is strictly
lower than $Tag, or $null when there is none.
.DESCRIPTION
Without a previous tag GitHub diffs against the latest release, which
can be a 1.5.378 maintenance release on another branch; the 2.0.0
notes generated that way exceeded the 125000-character body limit.
Choosing by version instead of publication time keeps a servicing
promotion (2.0.1 after 2.1.0) on its own line. Tags that are not
semantic versions, such as the four-component 1.5.378.x, are ignored.
#>
param(
[Parameter(Mandatory)][string]$Tag,
[AllowEmptyCollection()][string[]]$ReleaseTags = @()
)
$target = $null
if (-not [System.Management.Automation.SemanticVersion]::TryParse($Tag, [ref]$target)) {
throw "Release tag '$Tag' is not a semantic version."
}
$best = $null
$bestTag = $null
foreach ($candidateTag in $ReleaseTags) {
$candidate = $null
if (-not [System.Management.Automation.SemanticVersion]::TryParse($candidateTag, [ref]$candidate)) {
continue
}
if ($candidate.Major -ne $target.Major -or $candidate -ge $target) {
continue
}
if ($null -eq $best -or $candidate -gt $best) {
$best = $candidate
$bestTag = $candidateTag
}
}
return $bestTag
}
function Test-StablePackageVersion {
<#
.SYNOPSIS
Returns $true when a package version is an exact stable
major.minor.patch release, for example "2.0.0". The predicate
deliberately rejects prerelease/build metadata and an old
four-component NBGV build version such as "2.0.0.7": package
patches are explicit release decisions, never git height.
#>
param([Parameter(Mandatory)][string]$Version)
return $Version -match '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$'
}
function Test-PromotablePreviewPackageVersion {
<#
.SYNOPSIS
Returns $true when a package version is a public release-line
preview, exactly "major.minor.patch-preview.N", for example
"2.0.0-preview.6". This is the only preview shape release.yml may
promote to nuget.org.
.DESCRIPTION
A canonical release/M.m branch is a public release for
Nerdbank.GitVersioning, so its previews carry no commit id. Every
other shape is rejected: "-preview.N.gabc123" and "+gabc123" are
non-public builds (master, PR and feature branches), and anything
else ("-rc.1", "-preview", a four-component version) is not a
numbered preview this repository ever produces. The label is matched
case-sensitively (-cmatch): "-PREVIEW.6" is not the shape NBGV emits.
#>
param([Parameter(Mandatory)][string]$Version)
return $Version -cmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-preview\.(0|[1-9]\d*)$'
}
function Test-CanonicalReleaseBranchRef {
<#
.SYNOPSIS
Returns $true only for the exact maintained release-line branch
naming this repository publishes stable packages from, e.g.
"refs/heads/release/2.0" or "refs/heads/release/2.1". Rejects the
historical three-component "release/2.0.0" naming, any other
"release/*" branch, and every non-release ref (including master).
.DESCRIPTION
The expression must stay byte-identical to version.json's
publicReleaseRefSpec (pinned by
VersionJsonPublicReleaseRefSpecMatchesThePolicyFunction) and to the
copy release.yml inlines before it is allowed to check out repository
code. Two properties of that expression are load-bearing:
- The leading "(?-i)" forces a case-sensitive match. PowerShell's
-match is case-insensitive by default while Nerdbank.GitVersioning
matches publicReleaseRefSpec case-sensitively (measured: nbgv
3.7.115 reports PublicRelease=False on "Release/2.0"). Without it
this helper would report "refs/heads/Release/2.0" as canonical and
nuget-publish.yml would publish previews from a branch that
version.json does not recognize as a release line at all.
- Each component is "(0|[1-9]\d*)", not "\d+", so a leading zero is
not a second spelling of a release line. "release/02.0" otherwise
makes nbgv treat the branch as a public release (measured), which
gates the stable Docker version/latest aliases, while
Test-CanonicalReleaseBranchForPackageVersion compares the branch
components to the version as text and rejects it - a branch that
can move stable image aliases but never ship the matching package.
#>
param([Parameter(Mandatory)][string]$Ref)
return $Ref -match '(?-i)^refs/heads/release/(0|[1-9]\d*)\.(0|[1-9]\d*)$'
}
function Test-CanonicalReleaseBranchForPackageVersion {
<#
.SYNOPSIS
Returns $true only when a canonical release/M.m branch matches the
major/minor components of an exact stable M.m.p package version.
This prevents a 2.1.0 candidate being built or promoted from
release/2.0 (or the inverse).
.DESCRIPTION
Both sides are restricted to canonical decimal components so the
comparison below can stay textual: "release/02.0" is not an alternate
spelling of the 2.0 line, it is simply not a release line. See
Test-CanonicalReleaseBranchRef for why case and leading zeroes matter.
-AllowPreview additionally accepts a public release-line preview
(Test-PromotablePreviewPackageVersion, e.g. "2.0.0-preview.6") under
the same major/minor rule. Only release.yml's explicit preview
promotion passes it; the stable gates in nuget-publish.yml and
release.yml never do.
#>
param(
[Parameter(Mandatory)][string]$Ref,
[Parameter(Mandatory)][string]$Version,
[switch]$AllowPreview
)
$isStable = Test-StablePackageVersion -Version $Version
$isPreview = $AllowPreview -and (Test-PromotablePreviewPackageVersion -Version $Version)
if (-not ($isStable -or $isPreview)) {
return $false
}
$branchMatch = [regex]::Match(
$Ref, '(?-i)^refs/heads/release/(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)$')
if (-not $branchMatch.Success) {
return $false
}
$versionMatch = [regex]::Match(
$Version, '^(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-preview\.(?:0|[1-9]\d*))?$')
return $versionMatch.Success -and
$branchMatch.Groups['major'].Value -eq $versionMatch.Groups['major'].Value -and
$branchMatch.Groups['minor'].Value -eq $versionMatch.Groups['minor'].Value
}