Files
UA-.NETStandard/tests/Opc.Ua.Tools.Tests/CiPathRelevanceTests.cs
Marc Schier e9313ef957 Only use GitHub action for CI and nightly, establish release-branch-only package publication (#4513)
## Description

Two related changes to how the 2.0 line is built and shipped.

### 1. Release-branch-only package publication

- `master` produces development previews only.
- Stable packages can only be built from a canonical
`release/<major>.<minor>` branch and are promoted manually from the
exact signed candidate artifact.
- Core packages use explicit `major.minor.patch` versions (`2.0.0`,
`2.0.1`, then `2.1.0`); git height never changes a stable package patch.
- XRegistry, WoT Connectivity, Vision, Robotics, Redundancy,
Positioning, OpenUSD, ISA95, AI, DI, the relevant MCP extensions, and
OpenUSD connector tools remain `-preview.N` while following the core
numeric version.
- nuget.org receives only non-`.Debug` package IDs; GitHub Packages
retains the complete signed Release/Debug candidate.
- `docs/ReleaseProcess.md` is a reproducible runbook for developers and
agents, with each step stating its completion evidence.

Promotion is source-bound and byte-checked: `release.yml` checks out
nothing until the candidate SHA is known, and
`assert-published-packages-match.ps1` proves that any version already on
a feed holds *this* candidate's content before `--skip-duplicate` is
allowed to skip it. Canonical release-branch naming is matched
case-sensitively with no leading zeroes, and `version.json`'s
`publicReleaseRefSpec`, both policy helpers and the copy `release.yml`
inlines are pinned to one expression by tests.

### 2. PR CI moved to GitHub-hosted runners

- `.github/workflows/buildandtest.yml` (**CI**) now owns pull-request
validation outright, covering the Windows Debug/Release net48 + net10.0
solution builds and fast tests that only Azure Pipelines ran, alongside
the existing Linux/macOS, all-target and NativeAOT legs. `build-and-test
summary` remains the stable required check name.
- `.github/workflows/nightly.yml` is a new **manual-only**
(`workflow_dispatch`) full-scope workflow covering the Azure nightly
workload: all solutions x seven target profiles x Debug/Release, the
net472/netstandard profiles, Windows + Linux NativeAOT, and the
long-running and durable tiers.
- Shared deterministic discovery/batching lives in
`.github/scripts/get-ci-matrix.ps1` with a local composite action, so
both entry points expand the same workload manifest instead of
duplicating a large embedded script.
- `azure-pipelines.yml` is set to `pr: none`.
- The standalone pump sample image workflow is folded into the
consolidated **Images CI** workflow, which now builds all ten images and
publishes an `images summary` rollup suitable as a required check.

## Related Issues

No related issue provided.

## Checklist

- [ ] 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.
- [x] 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.

## Validation

- `Opc.Ua.Tools.Tests` full `net10.0` suite: **959 passed, 1 skipped, 0
failed**.
- Version/package-policy regression subset
(`PackageVersionPolicyTests`): **60 passed, 0 failed**.
- GitHub Actions **CI** run 35719807620: **149/149 jobs green** on
GitHub-hosted runners.
- Canonical release-branch rule verified against the pinned nbgv 3.7.115
by building throwaway branches: `release/2.0`, `release/0.9`,
`release/10.42` -> `PublicRelease=True`; `release/02.0`, `release/2.00`,
`Release/2.0` -> `False`.
- Package identity gate verified against a live nuget.org package:
served packages carry a repository `.signature.p7s`, so equality is
decided on content excluding that part. Re-signing and zip reordering
produce an identical digest; a one-byte content change does not.
- Synthetic Release-only, Debug-only, combined, preview, duplicate,
orphaned-symbol, mismatched-root and accidental-stable-family package
sets exercise the shared validator.
- Stable-release rehearsal in an isolated `release/2.0` clone:
`Opc.Ua.Di` packed as `2.0.0-preview.6` depending on stable Core
`2.0.0`; `Opc.Ua.Di.Client` likewise against stable Client `2.0.0`.
- All workflow YAML, `version.json`, the package-policy PowerShell
scripts and the internal release-runbook anchors parse.

## Known validation limits

- Full `UA.slnx` `net48` and `net10.0` local validation remains blocked
by pre-existing `NETSDK1005` and dependency-version failures in
unmodified fuzz/sample projects; the corresponding checklist items
intentionally stay open.
- The TRX-driven test verdict added for the intermittent at-exit host
stall is proven by `CiTestVerdictTests`; the stall itself has not
recurred since.
- `nightly.yml` has not yet been run against a full-scope Azure run for
tuple-level parity — see below.

## Operational follow-up

This remains a preparation PR targeting `master`. It does **not**
authorize a release.

Release sequence:

1. Merge this PR.
2. Create canonical `release/2.0`, advance `master` to
`2.1.0-preview.{height}`, and open a separate `2.0.0` version PR against
`release/2.0`.
3. Follow
[`docs/ReleaseProcess.md`](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/docs/ReleaseProcess.md)
for candidate dry-run, environment approval, promotion, tag/release
finalization, and next-patch preview setup.

CI migration steps still needing administrator access are tracked in
[`docs/DeveloperGuide.md#migration-status`](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/docs/DeveloperGuide.md#migration-status):
proving `nightly.yml` parity on a trusted SHA with the private fuzz
corpus provisioned, turning off definition 14's service-side PR
override, retiring the Azure full-scope schedules and push trigger, and
retiring the duplicate `azure-pipelines-preview.yml` publisher.

> **Before cutting `release/2.0`:** the `Release` ruleset requires the
Azure context for *every* `refs/heads/release/*`, but a new 2.x branch
inherits `pr: none`, so that context would never report and would block
every PR into the new line. Exclude the new ref from that ruleset, or
add a 2.x ruleset requiring `build-and-test summary`, as part of
creating the branch.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-09-23 15:47:43 +00:00

207 lines
9.9 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
#if NET10_0
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
namespace Opc.Ua.Tools.Tests
{
/// <summary>
/// Contract tests for .github/scripts/get-path-relevance.ps1, which decides
/// whether a pull request's changes are worth building.
///
/// This rule fails dangerously in one direction only. Calling a real change
/// irrelevant skips the build, test and NativeAOT jobs while the required
/// 'build-and-test summary' still reports success, so a broken change merges
/// behind a green check. Calling an irrelevant change relevant only wastes
/// runner time. The cases below therefore concentrate on inputs that are
/// easy to forget: generator inputs, test fixtures and build configuration.
/// </summary>
[TestFixture]
public sealed class CiPathRelevanceTests
{
/// <summary>
/// Anything that is not documentation has to be built. These are the
/// build inputs an extension allow-list would have missed.
/// </summary>
[TestCase("src/Opc.Ua.Types/BuiltIn/NodeId.cs", TestName = "Source")]
[TestCase("src/Opc.Ua.WotCon/Design/Opc.Ua.WotCon.NodeSet2.xml", TestName = "GeneratorNodeSet")]
[TestCase("src/Opc.Ua.WotCon/Design/Opc.Ua.WotCon.NodeSet2.csv", TestName = "GeneratorIdentifiers")]
[TestCase("tests/Opc.Ua.Types.Tests/Wot/Assets/example.jsonld", TestName = "TestFixture")]
[TestCase(".editorconfig", TestName = "EditorConfigEnforcedAtBuildTime")]
[TestCase("Directory.Packages.props", TestName = "CentralPackageVersions")]
[TestCase("version.json", TestName = "PackageVersion")]
[TestCase(".github/workflows/images.yml", TestName = "Workflow")]
[TestCase(".github/scripts/get-ci-matrix.ps1", TestName = "MatrixExpander")]
[TestCase("fuzzing/Common/Fuzz.Tests/FuzzTargetTestsBase.cs", TestName = "Fuzz")]
[TestCase("docs.txt", TestName = "FileNamedLikeTheDocsDirectory")]
[TestCase("src/readme.markdown", TestName = "NotMarkdownByExtension")]
public async Task ChangeIsBuildRelevantAsync(string path)
{
Assert.That(await EvaluateAsync(path).ConfigureAwait(false), Is.True);
}
/// <summary>
/// The only skippable class is documentation: Markdown anywhere, and the
/// docs/ tree, which holds nothing but Markdown and images.
/// </summary>
[TestCase("README.md", TestName = "RootMarkdown")]
[TestCase("docs/ReleaseProcess.md", TestName = "DocsMarkdown")]
[TestCase("docs/images/architecture.png", TestName = "DocsImage")]
[TestCase("src/Opc.Ua.Client/README.md", TestName = "NestedMarkdown")]
[TestCase("CONTRIBUTING.MD", TestName = "UppercaseExtension")]
public async Task ChangeIsSkippableAsync(string path)
{
Assert.That(await EvaluateAsync(path).ConfigureAwait(false), Is.False);
}
/// <summary>
/// One build-relevant file among documentation changes still has to
/// build; the rule stops at the first file that matters.
/// </summary>
[Test]
public async Task OneRelevantFileAmongDocumentationStillBuildsAsync()
{
bool relevant = await EvaluateAsync("README.md", "docs/DeveloperGuide.md", "src/Opc.Ua.Types/Guid.cs")
.ConfigureAwait(false);
Assert.That(relevant, Is.True);
}
/// <summary>
/// An empty diff changes nothing, so there is nothing to rebuild.
/// </summary>
[Test]
public async Task EmptyChangeSetIsNotRelevantAsync()
{
Assert.That(await EvaluateAsync().ConfigureAwait(false), Is.False);
}
/// <summary>
/// The workflow has to call this script rather than carry a second copy
/// of the rule, because only this one is tested.
/// </summary>
[Test]
public async Task WorkflowDelegatesTheDecisionToThisScriptAsync()
{
string workflow = Path.Combine(FindRepositoryRoot(), ".github", "workflows", "buildandtest.yml");
string source = await File.ReadAllTextAsync(workflow).ConfigureAwait(false);
Assert.That(source, Does.Contain("./.github/scripts/get-path-relevance.ps1 -ChangedFile $changed"));
}
/// <summary>
/// An unavailable base ref must fail the gate rather than look like an
/// empty, documentation-only diff. Both fixed-name required summaries
/// depend on these internal relevance checks.
/// </summary>
[TestCase("buildandtest.yml", "$relevantChanges = [bool]")]
[TestCase("docker-image.yml", "$relevantChanges = $false")]
public async Task WorkflowFailsClosedWhenGitDiffFailsAsync(string workflowName, string relevanceDecision)
{
string workflow = Path.Combine(FindRepositoryRoot(), ".github", "workflows", workflowName);
string source = await File.ReadAllTextAsync(workflow).ConfigureAwait(false);
int diff = source.IndexOf("$changed = @(& git diff --name-only", StringComparison.Ordinal);
int exitCheck = source.IndexOf("if ($LASTEXITCODE -ne 0)", diff, StringComparison.Ordinal);
int decision = source.IndexOf(relevanceDecision, diff, StringComparison.Ordinal);
Assert.Multiple(() =>
{
Assert.That(diff, Is.GreaterThanOrEqualTo(0));
Assert.That(exitCheck, Is.GreaterThan(diff), "git diff must be checked immediately.");
Assert.That(
decision,
Is.GreaterThan(exitCheck),
"Relevance must not be evaluated after a failed git diff.");
Assert.That(
source[exitCheck..decision],
Does.Contain("throw"),
"A failed diff must fail the gate rather than become an empty change set.");
});
}
private static async Task<bool> EvaluateAsync(params string[] changedFiles)
{
string root = FindRepositoryRoot();
using var process = new Process();
process.StartInfo.FileName = "pwsh";
process.StartInfo.WorkingDirectory = root;
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.RedirectStandardError = true;
PowerShellScriptOutput.ConfigureDeterministicOutput(process.StartInfo);
process.StartInfo.ArgumentList.Add("-NoProfile");
process.StartInfo.ArgumentList.Add("-File");
process.StartInfo.ArgumentList.Add(Path.Combine(root, ".github", "scripts", "get-path-relevance.ps1"));
if (changedFiles.Length != 0)
{
// 'pwsh -File' binds one argument per parameter, so an array has
// to arrive as a single comma-separated value. Omitting the
// parameter entirely is how an empty diff is expressed.
process.StartInfo.ArgumentList.Add("-ChangedFile");
process.StartInfo.ArgumentList.Add(string.Join(',', changedFiles));
}
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 = PowerShellScriptOutput.Normalize(await standardOutput.ConfigureAwait(false));
string error = await standardError.ConfigureAwait(false);
Assert.That(process.ExitCode, Is.Zero, output + error);
string verdict = output.Split('\n', StringSplitOptions.RemoveEmptyEntries).Last().Trim();
return bool.Parse(verdict);
}
private static string FindRepositoryRoot()
{
string? current = TestContext.CurrentContext.TestDirectory;
while (!string.IsNullOrWhiteSpace(current))
{
if (File.Exists(Path.Combine(current, ".github", "scripts", "get-path-relevance.ps1")))
{
return current;
}
current = Directory.GetParent(current)?.FullName;
}
throw new InvalidOperationException("Could not find the repository root.");
}
}
}
#endif