Files
Marc Schier 7e25e1a3d7 Extract remaining shared companion, rendering and packaging changes (#4806)
# Description

The shared AI deployment contracts and instance registration; DI
software-upload outcomes, digest handling and simulator; bounded
Positioning/Pumps reads; PubSub raw-data/matrix and security-provider
handling; Vision read-quality contracts; xRegistry resource writes and
instance projection; OpenUSD authoring, embedded viewport and
retained-frame capture APIs; standalone sample CLI controls; and the
generic Debug/RID package-policy and fork/Dependabot coverage-comment
fixes. The Linux native prerequisite step is retained for **Vision tests
only**.

## Related Issues

- Related tracking issue:
[#4461](https://github.com/OPCFoundation/UA-.NETStandard/issues/4461),
without automatic closure.
- Source work:
[#4462](https://github.com/OPCFoundation/UA-.NETStandard/pull/4462),
which remains intact for the coordinator's later integration.
- Immediate stacked predecessor:
[#4804](https://github.com/OPCFoundation/UA-.NETStandard/pull/4804).

## 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.
- [ ] 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.
- [ ] I have addressed **all** PR feedback received.
2026-10-17 07:52:02 +02:00

555 lines
26 KiB
YAML

name: Release to nuget.org
# Manual promotion of the exact signed package bytes produced by a successful
# nuget-publish.yml run on a canonical "release/<major>.<minor>" branch (e.g.
# release/2.0). The release environment provides the approval boundary (its
# deployment_branch_policy restricts execution to release/* branches) and the
# NuGet trusted publishing identity. The "channel" input states what is being
# promoted and must match the candidate: "stable" (the default) requires an
# exact stable release (no prerelease label or build metadata, e.g. "2.0.0");
# "preview" requires a public release-line preview (e.g. "2.0.0-preview.6").
# nuget-publish.yml only ever pushes previews to GitHub Packages, so this is
# the one path that puts a preview on nuget.org. See docs/ReleaseProcess.md
# ("Approved promotion" and "Preview release") for the full procedures,
# including how to pick a release_run_id and how to recover from a partial or
# failed promotion.
#
# One-time nuget.org trusted publishing policy:
# Repository Owner: OPCFoundation
# Repository: UA-.NETStandard
# Workflow File: release.yml
# Environment: release
on:
workflow_dispatch:
inputs:
release_run_id:
description: Successful nuget-publish.yml run ID from a release/<major>.<minor> branch
required: true
type: string
channel:
description: What the candidate is - an exact stable release, or a release-line preview (e.g. 2.0.0-preview.6)
required: false
type: choice
options:
- stable
- preview
default: stable
dry_run:
description: Validate the selected package set without publishing
required: false
type: boolean
default: false
permissions:
actions: read
contents: write
id-token: write
packages: write
concurrency:
group: nuget-release
cancel-in-progress: false
jobs:
publish:
name: Promote signed packages
runs-on: windows-2025
environment: release
steps:
# Deliberately no checkout here. This job holds the release environment
# plus packages:write and id-token:write, so no repository code may run
# before the workspace is bound to the candidate's own commit - the
# branch tip could otherwise have been changed after the candidate was
# built and would execute with those credentials. The step below
# therefore uses only the GitHub API and an inline branch-shape check,
# and the first and only checkout is at the candidate SHA.
- name: Setup .NET 10.0
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.0.401'
- name: Validate selected publish run
id: source
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_RUN_ID: ${{ inputs.release_run_id }}
run: |
if ($env:RELEASE_RUN_ID -notmatch '^\d+$') {
throw 'release_run_id must be a numeric GitHub Actions run ID.'
}
$runJson = gh api "repos/$($env:GITHUB_REPOSITORY)/actions/runs/$env:RELEASE_RUN_ID"
if ($LASTEXITCODE -ne 0) {
throw "Unable to read workflow run $env:RELEASE_RUN_ID."
}
$run = $runJson | ConvertFrom-Json
$workflowJson = gh api (
"repos/$($env:GITHUB_REPOSITORY)/actions/workflows/$($run.workflow_id)")
if ($LASTEXITCODE -ne 0) {
throw "Unable to identify the workflow for run $env:RELEASE_RUN_ID."
}
$workflow = $workflowJson | ConvertFrom-Json
if ($workflow.path -cne '.github/workflows/nuget-publish.yml') {
throw "Run $env:RELEASE_RUN_ID belongs to '$($workflow.path)', not nuget-publish.yml."
}
if ($run.status -cne 'completed' -or $run.conclusion -cne 'success') {
throw (
"Run $env:RELEASE_RUN_ID is not successful: " +
"status=$($run.status), conclusion=$($run.conclusion).")
}
if (-not $run.head_branch.StartsWith(
'release/',
[StringComparison]::OrdinalIgnoreCase)) {
throw (
"Run $env:RELEASE_RUN_ID came from '$($run.head_branch)'. " +
'Only release/* branches can be promoted.')
}
# Inlined on purpose rather than dot-sourced from
# .azurepipelines/package-version-policy.ps1: nothing from the
# repository may execute before the candidate checkout below. This
# is the same shape as Test-CanonicalReleaseBranchRef, and
# PackageVersionPolicyTests pins the two together so they cannot
# drift apart.
if ("refs/heads/$($run.head_branch)" -notmatch '(?-i)^refs/heads/release/(0|[1-9]\d*)\.(0|[1-9]\d*)$') {
throw (
"Run $env:RELEASE_RUN_ID came from '$($run.head_branch)', which is not a " +
"canonical release/<major>.<minor> branch (for example release/2.0). Only " +
'that naming may be promoted; see docs/ReleaseProcess.md.')
}
if ($env:GITHUB_REF -cne "refs/heads/$($run.head_branch)") {
throw (
"This release.yml dispatch runs from '$($env:GITHUB_REF)', but the selected " +
"candidate was built from 'refs/heads/$($run.head_branch)'. Dispatch " +
'release.yml from that exact canonical release branch so the validation ' +
'policy and promotion code are source-bound to the candidate.')
}
if ($run.head_repository.full_name -cne $env:GITHUB_REPOSITORY) {
throw (
"Run $env:RELEASE_RUN_ID came from '$($run.head_repository.full_name)', " +
"not '$($env:GITHUB_REPOSITORY)'.")
}
"commit=$($run.head_sha)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"branch=$($run.head_branch)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"artifact=opcua-packages-$env:RELEASE_RUN_ID" |
Out-File -FilePath $env:GITHUB_OUTPUT -Append
Write-Host (
"Selected successful nuget-publish.yml run $env:RELEASE_RUN_ID " +
"for $($run.head_branch) at $($run.head_sha).")
- name: Check out the candidate commit
# First and only checkout, and deliberately after the validation step
# above: the dispatch ref pins a branch, not a commit, so checking out
# the branch tip would let a commit that landed after the candidate was
# built supply the promotion logic. Binding to the candidate's own SHA
# makes the validator, the policy scripts and the package bytes all
# describe the same source.
uses: actions/checkout@v6
with:
ref: ${{ steps.source.outputs.commit }}
- name: Download signed promotion artifact
uses: actions/download-artifact@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ inputs.release_run_id }}
name: ${{ steps.source.outputs.artifact }}
path: promotion
- name: Validate promotion manifest and package bytes
shell: pwsh
env:
RELEASE_RUN_ID: ${{ inputs.release_run_id }}
RELEASE_BRANCH: ${{ steps.source.outputs.branch }}
RELEASE_COMMIT: ${{ steps.source.outputs.commit }}
RELEASE_CHANNEL: ${{ inputs.channel }}
run: |
. ./.azurepipelines/package-version-policy.ps1
$manifestPath = 'promotion/release-manifest.json'
if (-not (Test-Path -LiteralPath $manifestPath)) {
throw "The selected run artifact contains no release-manifest.json."
}
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
$expectedRef = "refs/heads/$($env:RELEASE_BRANCH)"
if (($manifest.schemaVersion -ne 1 -and $manifest.schemaVersion -ne 2) -or
$manifest.repository -cne $env:GITHUB_REPOSITORY -or
$manifest.workflow -cne '.github/workflows/nuget-publish.yml' -or
$manifest.runId -cne $env:RELEASE_RUN_ID -or
$manifest.ref -cne $expectedRef -or
$manifest.commit -cne $env:RELEASE_COMMIT) {
throw (
"The promotion manifest does not match nuget-publish.yml run " +
"$($env:RELEASE_RUN_ID) on $expectedRef.")
}
$expectedBaseVersion = if ($manifest.schemaVersion -eq 2) {
$manifest.basePackageVersion
}
else {
$manifest.packageVersion
}
# The dispatcher states which kind of release this is, and the
# candidate must be exactly that: a stable promotion can never
# publish a preview by accident, nor the reverse. A preview must be
# a public release-line build ("M.m.p-preview.N", no commit id), so
# master or feature-branch previews can never reach nuget.org. See
# docs/ReleaseProcess.md ("Approved promotion", "Preview release").
$channel = $env:RELEASE_CHANNEL
if ($channel -cne 'stable' -and $channel -cne 'preview') {
throw "channel must be 'stable' or 'preview', not '$channel'."
}
if ($channel -ceq 'stable' -and
-not (Test-StablePackageVersion -Version $expectedBaseVersion)) {
throw (
"Run $($env:RELEASE_RUN_ID) produced base version " +
"'$expectedBaseVersion', which is not an exact stable release. Only a " +
'stable candidate (no prerelease label or build metadata) may be ' +
"promoted with channel 'stable'; dispatch with channel 'preview' to " +
'publish a release-line preview.')
}
if ($channel -ceq 'preview' -and
-not (Test-PromotablePreviewPackageVersion -Version $expectedBaseVersion)) {
throw (
"Run $($env:RELEASE_RUN_ID) produced base version " +
"'$expectedBaseVersion', which is not a release-line preview. Only an " +
"'M.m.p-preview.N' candidate without a commit id may be promoted with " +
"channel 'preview'.")
}
# schemaVersion 1 manifests predate the recorded channel. They stay
# promotable as stable, but a preview must prove its channel.
if ($channel -ceq 'preview' -and $manifest.schemaVersion -ne 2) {
throw (
"The promotion manifest is schemaVersion $($manifest.schemaVersion), which " +
"records no channel. Only a schemaVersion 2 candidate may be promoted " +
"with channel 'preview'.")
}
if ($manifest.schemaVersion -eq 2 -and $manifest.channel -cne $channel) {
throw (
"The promotion manifest records channel '$($manifest.channel)', but this " +
"promotion was dispatched with channel '$channel'.")
}
if (-not (Test-CanonicalReleaseBranchForPackageVersion `
-Ref $expectedRef `
-Version $expectedBaseVersion `
-AllowPreview:($channel -ceq 'preview'))) {
throw (
"Candidate version '$expectedBaseVersion' does not match release branch " +
"'$expectedRef'. An M.m.p candidate must be promoted only from " +
'the matching release/M.m branch.')
}
$currentPath = 'promotion/current-package-set.json'
./.azurepipelines/validate-nuget-package-set.ps1 `
-PackageDirectory promotion `
-ManifestPath $currentPath `
-ExpectedVersion $expectedBaseVersion `
-RequireDebug `
-VerifySignatures
$current = Get-Content -LiteralPath $currentPath -Raw | ConvertFrom-Json
if ($current.packageCount -ne $manifest.packageCount -or
$current.symbolPackageCount -ne $manifest.symbolPackageCount -or
$current.debugPackageCount -ne $manifest.debugPackageCount) {
throw 'The downloaded package counts do not match the promotion manifest.'
}
$expectedArchives = @($manifest.archives | Sort-Object id, type, file) |
ConvertTo-Json -Depth 5 -Compress
$actualArchives = @($current.archives | Sort-Object id, type, file) |
ConvertTo-Json -Depth 5 -Compress
if ($actualArchives -cne $expectedArchives) {
throw 'The downloaded package names, identities, versions, or hashes have changed.'
}
Write-Host (
"Validated $($current.packageCount) exact package bytes at version " +
"$($current.basePackageVersion) from run $($env:RELEASE_RUN_ID).")
- name: Verify preview package ordering
# Last gate before an irreversible publication: the preview-only
# families carry the number committed in preview-version.props, which
# cannot track the growing "-preview.<height>" series that ordinary
# development publishes. Re-prove the ordering here (including on a
# dry run) rather than trusting that nothing was published between
# candidate creation and approval. See docs/ReleaseProcess.md.
shell: pwsh
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
./.azurepipelines/validate-preview-package-ordering.ps1 `
-ManifestPath 'promotion/current-package-set.json'
- name: Verify already-published package identity
# "--skip-duplicate" below makes a failed promotion recoverable by
# re-running the same candidate, but on its own it accepts *any*
# pre-existing id/version as a successful no-op. Prove first that every
# version this candidate already occupies really is this candidate's,
# so a foreign build that claimed an immutable version can never be
# mistaken for a completed publication. Runs on a dry run too: it is
# read-only and is exactly what a rehearsal should establish.
shell: pwsh
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
./.azurepipelines/assert-published-packages-match.ps1 `
-PackageDirectory promotion `
-ManifestPath 'promotion/current-package-set.json' `
-Feed 'nuget.org' `
-ExcludeDebugPackages
./.azurepipelines/assert-published-packages-match.ps1 `
-PackageDirectory promotion `
-ManifestPath 'promotion/current-package-set.json' `
-Feed 'GitHub Packages'
- name: NuGet login
if: ${{ !inputs.dry_run }}
id: login
uses: NuGet/login@v1
with:
user: ${{ secrets.NUGET_USER }}
- name: Push to nuget.org
if: ${{ !inputs.dry_run }}
shell: pwsh
env:
NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }}
run: |
. ./.azurepipelines/package-version-policy.ps1
Add-Type -AssemblyName System.IO.Compression.FileSystem
$packages = @(Get-ChildItem promotion -Filter '*.nupkg' -File |
Where-Object {
$archive = [IO.Compression.ZipFile]::OpenRead($_.FullName)
try {
$nuspec = @($archive.Entries | Where-Object {
$_.FullName -notmatch '/' -and $_.FullName.EndsWith('.nuspec')
})
if ($nuspec.Count -ne 1) {
throw "Unable to identify package '$($_.Name)'."
}
$reader = [IO.StreamReader]::new($nuspec[0].Open())
try {
[xml]$metadata = $reader.ReadToEnd()
}
finally {
$reader.Dispose()
}
$id = [string]$metadata.package.metadata.id
-not (Test-DebugPackageId -PackageId $id)
}
finally {
$archive.Dispose()
}
} |
Sort-Object Name)
if ($packages.Count -eq 0) {
throw 'The candidate contains no non-Debug packages to push to nuget.org.'
}
Write-Host (
"Publishing $($packages.Count) non-Debug package(s) to nuget.org. " +
'Debug package IDs remain in the signed candidate artifact and are ' +
'published only to GitHub Packages.')
foreach ($package in $packages) {
# --skip-duplicate keeps a partially completed promotion
# re-runnable. The preflight above rejects a foreign existing
# package; the post-push verification below closes the remaining
# race where another publisher claims the version between that
# check and this push.
dotnet nuget push $package.FullName `
--source 'https://api.nuget.org/v3/index.json' `
--symbol-source 'https://api.nuget.org/v3/index.json' `
--api-key $env:NUGET_API_KEY `
--skip-duplicate
if ($LASTEXITCODE -ne 0) {
throw "nuget.org push failed for '$($package.Name)'."
}
}
./.azurepipelines/assert-published-packages-match.ps1 `
-PackageDirectory promotion `
-ManifestPath 'promotion/current-package-set.json' `
-Feed 'nuget.org' `
-ExcludeDebugPackages `
-RequirePresent
- name: Push to GitHub Packages
# nuget-publish.yml deliberately never pushes a stable candidate to
# any feed automatically (see its "Skip feed publication" step); this
# is the one place a stable release also gets mirrored to GitHub
# Packages, gated behind the same approval and dry-run switch as the
# nuget.org push above. A preview candidate was already pushed here by
# its own nuget-publish.yml run, so this loop is a verified no-op for
# it: the preflight and post-push checks prove those are its bytes.
if: ${{ !inputs.dry_run }}
shell: pwsh
env:
GITHUB_PACKAGES_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# GitHub Packages retains the complete signed candidate, including
# .Debug package IDs. Public nuget.org releases above intentionally
# exclude them; see docs/ReleaseProcess.md.
$packages = @(Get-ChildItem promotion -Filter '*.nupkg' -File | Sort-Object Name)
foreach ($package in $packages) {
# As above, the preflight and post-push checks jointly prove that
# a duplicate is this candidate rather than a package published
# in the check-then-push window.
dotnet nuget push $package.FullName `
--source 'https://nuget.pkg.github.com/OPCFoundation/index.json' `
--api-key $env:GITHUB_PACKAGES_TOKEN `
--skip-duplicate `
--no-symbols
if ($LASTEXITCODE -ne 0) {
throw "GitHub Packages push failed for '$($package.Name)'."
}
}
./.azurepipelines/assert-published-packages-match.ps1 `
-PackageDirectory promotion `
-ManifestPath 'promotion/current-package-set.json' `
-Feed 'GitHub Packages' `
-GitHubToken $env:GITHUB_PACKAGES_TOKEN `
-RequirePresent
- name: Tag published package candidate
if: ${{ !inputs.dry_run }}
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_COMMIT: ${{ steps.source.outputs.commit }}
RELEASE_CHANNEL: ${{ inputs.channel }}
run: |
$manifest = Get-Content -LiteralPath 'promotion/release-manifest.json' -Raw | ConvertFrom-Json
$tag = if ($manifest.schemaVersion -eq 2) {
[string]$manifest.basePackageVersion
}
else {
[string]$manifest.packageVersion
}
if ([string]::IsNullOrWhiteSpace($tag)) {
throw 'The promotion manifest does not contain a package version for the release tag.'
}
$tagRef = gh api "repos/$env:GITHUB_REPOSITORY/git/ref/tags/$tag" 2>&1
if ($LASTEXITCODE -eq 0) {
$tagObject = $tagRef | ConvertFrom-Json
if ($tagObject.object.type -cne 'commit' -or
$tagObject.object.sha -cne $env:RELEASE_COMMIT) {
throw "Existing tag '$tag' does not point to candidate commit $env:RELEASE_COMMIT."
}
}
elseif ($tagRef -match 'HTTP 404') {
gh api --method POST "repos/$env:GITHUB_REPOSITORY/git/refs" `
-f "ref=refs/tags/$tag" `
-f "sha=$env:RELEASE_COMMIT"
if ($LASTEXITCODE -ne 0) {
throw "Failed to create tag '$tag' for candidate commit $env:RELEASE_COMMIT."
}
}
else {
throw "Unable to determine whether tag '$tag' already exists: $tagRef"
}
$release = gh api "repos/$env:GITHUB_REPOSITORY/releases/tags/$tag" 2>&1
$isPrerelease = $env:RELEASE_CHANNEL -ceq 'preview'
if ($LASTEXITCODE -eq 0) {
$releaseObject = $release | ConvertFrom-Json
if ($releaseObject.draft -or
$releaseObject.target_commitish -cne $env:RELEASE_COMMIT -or
[bool]$releaseObject.prerelease -ne $isPrerelease) {
throw "Existing GitHub Release '$tag' does not match the promoted candidate."
}
}
elseif ($release -match 'HTTP 404') {
# GitHub generates notes against the latest release when no
# previous tag is given. That is a 1.5.378 maintenance release
# on another branch, so the 2.0.0 notes listed every commit
# since the branches split and exceeded the 125000-character
# body limit (HTTP 422). Start from the greatest lower release
# of the same major (see Get-PreviousReleaseTag) and cap the
# body regardless.
. ./.azurepipelines/package-version-policy.ps1
$releaseTags = @(gh api --paginate "repos/$env:GITHUB_REPOSITORY/releases?per_page=100" `
--jq '.[] | select(.draft | not) | .tag_name')
if ($LASTEXITCODE -ne 0) {
throw 'Unable to list existing GitHub Releases.'
}
$previousTag = Get-PreviousReleaseTag -Tag $tag -ReleaseTags $releaseTags
$notesRequest = @('--method', 'POST', "repos/$env:GITHUB_REPOSITORY/releases/generate-notes",
'-f', "tag_name=$tag", '-f', "target_commitish=$env:RELEASE_COMMIT")
if ($previousTag) {
$notesRequest += @('-f', "previous_tag_name=$previousTag")
}
$notes = gh api @notesRequest | ConvertFrom-Json
if ($LASTEXITCODE -ne 0) {
throw "Failed to generate release notes for '$tag'."
}
$body = [string]$notes.body
$maxBodyLength = 120000
if ($body.Length -gt $maxBodyLength) {
$compare = if ($previousTag) {
"https://github.com/$env:GITHUB_REPOSITORY/compare/$previousTag...$tag"
}
else {
"https://github.com/$env:GITHUB_REPOSITORY/commits/$tag"
}
$cut = $body.LastIndexOf("`n", $maxBodyLength)
$body = $body.Substring(0, [Math]::Max($cut, 0)) +
"`n`n_The generated notes were truncated to fit the GitHub Release size limit. Full changelog: $($compare)_`n"
}
$notesFile = Join-Path $env:RUNNER_TEMP 'release-notes.md'
Set-Content -LiteralPath $notesFile -Value $body -Encoding utf8NoBOM
$arguments = @('release', 'create', $tag, '--target', $env:RELEASE_COMMIT,
'--title', [string]$notes.name, '--notes-file', $notesFile)
if ($isPrerelease) {
$arguments += '--prerelease'
}
gh @arguments
if ($LASTEXITCODE -ne 0) {
throw "Failed to create GitHub Release '$tag'."
}
}
else {
throw "Unable to determine whether GitHub Release '$tag' already exists: $release"
}
- name: Summary
if: always()
shell: pwsh
env:
RELEASE_RUN_ID: ${{ inputs.release_run_id }}
RELEASE_BRANCH: ${{ steps.source.outputs.branch }}
RELEASE_CHANNEL: ${{ inputs.channel }}
RELEASE_DRY_RUN: ${{ inputs.dry_run }}
run: |
$manifestPath = 'promotion/release-manifest.json'
if (Test-Path -LiteralPath $manifestPath) {
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
$version = if ($manifest.schemaVersion -eq 2) {
$manifest.basePackageVersion
}
else {
$manifest.packageVersion
}
$count = $manifest.packageCount
}
else {
$version = 'unavailable'
$count = 'unavailable'
}
@"
### nuget.org release
- Source run: ``$($env:RELEASE_RUN_ID)``
- Source branch: ``$($env:RELEASE_BRANCH)``
- Channel: ``$($env:RELEASE_CHANNEL)``
- Version: ``$version``
- Packages: ``$count``
- Dry run: ``$($env:RELEASE_DRY_RUN)``
"@ | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY