Files
UA-.NETStandard/tests/Opc.Ua.Tools.Tests/DebugPackageIdConventionTests.cs
T
Marc SchierandCopilot Autofix powered by AI 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

267 lines
10 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.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using NUnit.Framework;
namespace Opc.Ua.Tools.Tests
{
/// <summary>
/// Guards the Debug package-set invariant that .github/workflows/nuget-publish.yml
/// depends on: the Release and Debug pack legs of the same commit are aggregated
/// into a single directory, so every project that packs in both configurations
/// must produce a distinct package ID. A project satisfies that either by
/// renaming itself to "&lt;id&gt;.Debug" or by excluding itself from the Debug leg.
/// Without this, a project silently ships no Debug counterpart. See
/// docs/ReleaseProcess.md.
/// </summary>
[TestFixture]
public sealed partial class DebugPackageIdConventionTests
{
[Test]
public void EveryPackableProjectRenamesOrExcludesItselfInDebug()
{
string repositoryRoot = FindRepositoryRoot();
List<string> projects = GetPackSolutionProjects(repositoryRoot);
Assert.That(
projects,
Is.Not.Empty,
"The pack solution must contain projects.");
var violations = new List<string>();
int renamed = 0;
int excluded = 0;
foreach (string project in projects)
{
string text = ReadProjectWithLocalImports(project);
if (!OptsIntoPacking(text))
{
continue;
}
if (HasDebugConditionedElement(text, "PackageId"))
{
renamed++;
}
else if (HasDebugConditionedElement(text, "IsPackable"))
{
excluded++;
}
else
{
violations.Add(
Path.GetRelativePath(repositoryRoot, project).Replace('\\', '/'));
}
}
Assert.That(
violations,
Is.Empty,
"Each of these packable projects would pack under its Release package ID in " +
"the Debug leg of nuget-publish.yml, colliding with the Release leg during " +
"aggregation. Add a Debug PackageId override or exclude the project from " +
"the Debug pack:" +
Environment.NewLine +
string.Join(Environment.NewLine, violations.Order(StringComparer.Ordinal)));
Assert.Multiple(() => {
Assert.That(
renamed,
Is.GreaterThan(0),
"The check found no renamed project, so it is not actually observing " +
"the repository's packable projects.");
Assert.That(
excluded,
Is.GreaterThan(0),
"The check found no opted-out project, so the opt-out branch is unproven.");
});
TestContext.Out.WriteLine(
$"{renamed} project(s) rename to .Debug, {excluded} opt out of the Debug pack.");
}
/// <summary>
/// Mirrors .azurepipelines/generate-slnx.ps1: the pack solution is UA.slnx
/// with the test, fuzzing and solution-item folders removed.
/// </summary>
private static List<string> GetPackSolutionProjects(string repositoryRoot)
{
string solutionPath = Path.Combine(repositoryRoot, "UA.slnx");
Assert.That(File.Exists(solutionPath), Is.True, "UA.slnx must exist.");
string[] excludedPrefixes = ["/tests/", "/fuzzing/", "/Solution Items/"];
XDocument solution = XDocument.Load(solutionPath);
var projects = new List<string>();
foreach (XElement folder in solution.Root?.Elements("Folder") ?? [])
{
string name = folder.Attribute("Name")?.Value ?? string.Empty;
if (excludedPrefixes.Any(prefix =>
name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ||
name.Equals(prefix.TrimEnd('/'), StringComparison.OrdinalIgnoreCase)))
{
continue;
}
foreach (XElement project in folder.Elements("Project"))
{
string? path = project.Attribute("Path")?.Value;
if (string.IsNullOrWhiteSpace(path))
{
continue;
}
string full = Path.GetFullPath(Path.Combine(
repositoryRoot,
path.Replace('/', Path.DirectorySeparatorChar)));
if (File.Exists(full))
{
projects.Add(full);
}
}
}
return projects;
}
/// <summary>
/// Returns the project text concatenated with every repository-local file it
/// imports, transitively. Several projects - the source-generator pack
/// projects, for example - declare both their packability and their Debug
/// rename in a shared .targets file, so reading the .csproj alone classifies
/// them incorrectly.
/// </summary>
private static string ReadProjectWithLocalImports(string projectPath)
{
var builder = new StringBuilder();
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var pending = new Queue<string>();
pending.Enqueue(projectPath);
while (pending.Count > 0)
{
string current = pending.Dequeue();
if (!visited.Add(current) || !File.Exists(current))
{
continue;
}
string text = File.ReadAllText(current);
builder.AppendLine(text);
string? directory = Path.GetDirectoryName(current);
if (directory is null)
{
continue;
}
foreach (Match import in ImportProjectRegex().Matches(text))
{
// Only repository-local, statically resolvable imports are
// followed. An import whose path is computed from a property
// other than MSBuildThisFileDirectory cannot be resolved here.
string relative = import.Groups["path"].Value.Replace(
"$(MSBuildThisFileDirectory)",
string.Empty,
StringComparison.Ordinal);
if (relative.Contains("$(", StringComparison.Ordinal))
{
continue;
}
pending.Enqueue(Path.GetFullPath(Path.Combine(
directory,
relative.Replace('\\', Path.DirectorySeparatorChar))));
}
}
return builder.ToString();
}
/// <summary>
/// common.props sets IsPackable=false for the whole repository, so a project
/// produces a package only when it - or one of its imports - opts back in.
/// </summary>
private static bool OptsIntoPacking(string projectText)
{
string outsideDebugGroups = DebugPropertyGroupRegex()
.Replace(projectText, string.Empty);
return IsPackableElementRegex()
.Matches(outsideDebugGroups)
.Any(match => match.Groups["value"].Value.Trim()
.Equals("true", StringComparison.OrdinalIgnoreCase));
}
private static bool HasDebugConditionedElement(string projectText, string elementName)
{
return DebugPropertyGroupRegex()
.Matches(projectText)
.Any(group => group.Groups["body"].Value.Contains(
$"<{elementName}>",
StringComparison.Ordinal));
}
[GeneratedRegex(
@"<PropertyGroup\s+Condition\s*=\s*""'\$\(Configuration\)'\s*==\s*'Debug'""\s*>(?<body>.*?)</PropertyGroup>",
RegexOptions.Singleline)]
private static partial Regex DebugPropertyGroupRegex();
[GeneratedRegex(@"<IsPackable>(?<value>[^<]*)</IsPackable>")]
private static partial Regex IsPackableElementRegex();
[GeneratedRegex(@"<Import\s+[^>]*Project\s*=\s*""(?<path>[^""]+)""")]
private static partial Regex ImportProjectRegex();
private static string FindRepositoryRoot()
{
string? current = TestContext.CurrentContext.TestDirectory;
while (!string.IsNullOrWhiteSpace(current))
{
if (File.Exists(Path.Combine(current, "version.targets")))
{
return current;
}
current = Directory.GetParent(current)?.FullName;
}
throw new InvalidOperationException("Could not find the repository root.");
}
}
}
#endif