Files
UA-.NETStandard/tests/Opc.Ua.Tools.Tests/CiTestDurationsScriptTests.cs
romanett fbaabf5b28 Cut CI runner minutes: duration-packed test batches, CodeQL on Linux/daily, cancellable branch packing (#4694)
Reduces GitHub Actions runner minutes without removing any OS, target
framework, test project or check. All figures come from master CI run
37520359929 (158 jobs, ~1,880 runner-minutes) and the last two weeks of
run history.

## 1. Test batches packed by duration (`get-ci-matrix.ps1`)

Previously the expander picked the **smallest** batch size that fit the
256-job limit, which meant 2 projects per job and 33 jobs per profile.
Job logs show ~45% of the test matrix (674 of ~1,516 min) went to
compiling. Each batch rebuilds the shared dependency graph: the first
project's build took 3.8–4.9 min, the second only 0.4–0.9 min.

- Projects are now packed in path order (which keeps `PubSub.*`,
`Redundancy.*`, `WotCon.*` together) up to a 30-minute weight target,
using `.github/ci-test-durations.json`. Each weight is the slowest PR
profile's test minutes, rounded up, plus 1.
- A batch never holds more projects than the job timeout can budget for:
at most 7, given 20 + n×45 ≤ 350.
- If the matrix would exceed the job limit, the target is raised
instead.
- **PR test matrix: 132 → 60 jobs** (15 per profile; macOS 33 → 15).
Each batch is estimated at ≤36 min on the slowest runner, comparable to
today's slowest jobs (37–43 min), so wall-clock should be about the
same. Expected saving is roughly 300+ runner-min per run, plus less
queueing for the scarce macOS runners.
- The weekly full scope shrinks the same way (still 13 profiles).
- The table only steers balance: a project missing from it gets
`defaultMinutes`, so it is still scheduled.
- `run-dotnet-tests.ps1` now records per-project
`buildSeconds`/`testSeconds` (batch summary and job summary). The new
`update-test-durations.ps1` refreshes the table from any run's
`dotnet-results-*` artifacts.
- `discover` output `batch_size` is renamed to `batch_target_minutes`.
Nothing consumed it.

New `CiMatrixScriptTests` cases:
- each profile runs each of its projects exactly once;
- only a lone project may exceed the target;
- PR profiles average ≥3 projects per job;
- the duration table names only existing projects.

## 2 + 3. CodeQL (`codeql-analysis.yml`)

- **No longer runs on every push to master.** Instead, a daily schedule
on master (was weekly) gives an analysis that always completes. Each
analysis takes ~3 h, and with `cancel-in-progress: true` per branch, 49
of 76 push runs in two weeks were cancelled by the next merge before
uploading anything. `master378`/`release/*`/`develop/*` keep their push
trigger. PRs are still analyzed as before.
- Only PR runs are cancelled when superseded. Push and scheduled runs
always finish.
- **Runs on `ubuntu-latest` instead of `windows-2025`.** Only net10.0 is
built, so the extracted sources are identical. The traced Windows build
alone took 70 min, against 12 min for the untraced Linux `UA.slnx`
net10.0 leg. This PR touches a `.cs` file, so its own CodeQL run is the
first measurement.

## 4. NuGet packing on feature branches (`nuget-publish.yml`)

298 of ~400 runs in two weeks were feature-branch pushes, each about 59
Windows-min across two signed builds.

- A superseded feature-branch run is now cancelled. Protected lines and
tags are never cancelled: they publish previews, and `release.yml`
promotes a run by ID. Each manual (`workflow_dispatch`) run gets its own
concurrency group, so a later push cannot cancel it.
- A new `changes` job skips packing when a feature branch, as a whole,
changes only documentation. It diffs against the merge base with master,
not the previous push, so a docs-only push that cancels a code push
cannot mask that code change. It uses the same `get-path-relevance.ps1`
rule as CI.
- Protected refs, tags, manual runs, and any branch whose merge base
cannot be resolved always pack. Checked locally for docs-only branch
(skip), code branch, protected ref, manual run and unknown commit (all
pack).

## Verification

- `CiMatrixScriptTests`, `CiPathRelevanceTests` and the new
`CiTestDurationsScriptTests` (covering `update-test-durations.ps1`): all
`*.Ci*` tests in `Opc.Ua.Tools.Tests` pass locally, 63/63.
- This PR's own CI run shows the new matrix. Compare its total
runner-minutes and longest test job against run 37520359929.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:00:19 +02:00

240 lines
11 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.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
namespace Opc.Ua.Tools.Tests
{
/// <summary>
/// Contract tests for .github/scripts/update-test-durations.ps1, which
/// rewrites the weights get-ci-matrix.ps1 packs test batches with. A
/// regression there would silently skew every batch of every later run,
/// so the selection rules are pinned against temporary summaries.
/// </summary>
[TestFixture]
[NonParallelizable]
public sealed class CiTestDurationsScriptTests
{
private string m_workDirectory = string.Empty;
private string m_tablePath = string.Empty;
private string m_resultsPath = string.Empty;
[SetUp]
public async Task SetUpAsync()
{
m_workDirectory = Path.Combine(Path.GetTempPath(), $"ci-durations-{Guid.NewGuid():N}");
m_resultsPath = Path.Combine(m_workDirectory, "results");
Directory.CreateDirectory(m_resultsPath);
m_tablePath = Path.Combine(m_workDirectory, "ci-test-durations.json");
await File.WriteAllTextAsync(
m_tablePath,
"""
{
"source": "seed",
"defaultMinutes": 5,
"projects": {
"Opc.Ua.Kept.Tests": 9,
"Opc.Ua.Slow.Tests": 2
}
}
""").ConfigureAwait(false);
}
[TearDown]
public void TearDown()
{
if (Directory.Exists(m_workDirectory))
{
Directory.Delete(m_workDirectory, recursive: true);
}
}
/// <summary>
/// A project's weight is its slowest profile's passed test time, rounded
/// up to whole minutes, plus one minute of incremental build.
/// </summary>
[Test]
public async Task WeightIsTheSlowestPassedProfilePlusOneMinuteAsync()
{
await WriteSummaryAsync("linux", ("tests/Opc.Ua.Slow.Tests/Opc.Ua.Slow.Tests.csproj", "passed", 130))
.ConfigureAwait(false);
await WriteSummaryAsync("macos", ("tests/Opc.Ua.Slow.Tests/Opc.Ua.Slow.Tests.csproj", "passed", 250))
.ConfigureAwait(false);
ScriptResult result = await RunScriptAsync().ConfigureAwait(false);
Assert.That(result.ExitCode, Is.Zero, result.Output);
Assert.That(await ReadWeightsAsync().ConfigureAwait(false), Does.ContainKey("Opc.Ua.Slow.Tests").WithValue(6));
}
/// <summary>
/// Failed and not-applicable records say nothing about a healthy run's
/// duration and must not move a weight, however long they took.
/// </summary>
[Test]
public async Task FailedAndNotApplicableRecordsAreIgnoredAsync()
{
await WriteSummaryAsync(
"windows",
("tests/Opc.Ua.Slow.Tests/Opc.Ua.Slow.Tests.csproj", "passed", 60),
("tests/Opc.Ua.Slow.Tests/Opc.Ua.Slow.Tests.csproj", "failed", 9999),
("tests/Opc.Ua.Kept.Tests/Opc.Ua.Kept.Tests.csproj", "not-applicable", 9999)).ConfigureAwait(false);
ScriptResult result = await RunScriptAsync().ConfigureAwait(false);
Assert.That(result.ExitCode, Is.Zero, result.Output);
Dictionary<string, int> weights = await ReadWeightsAsync().ConfigureAwait(false);
Assert.That(weights, Does.ContainKey("Opc.Ua.Slow.Tests").WithValue(2));
Assert.That(weights, Does.ContainKey("Opc.Ua.Kept.Tests").WithValue(9));
}
/// <summary>
/// A project the run did not execute keeps its weight, a newly seen one
/// is added, and the table keeps its default and records its source.
/// </summary>
[Test]
public async Task UnobservedProjectsKeepTheirWeightAndNewOnesAreAddedAsync()
{
await WriteSummaryAsync("linux", ("tests/Opc.Ua.New.Tests/Opc.Ua.New.Tests.csproj", "passed", 30))
.ConfigureAwait(false);
ScriptResult result = await RunScriptAsync().ConfigureAwait(false);
Assert.That(result.ExitCode, Is.Zero, result.Output);
Dictionary<string, int> weights = await ReadWeightsAsync().ConfigureAwait(false);
Assert.That(weights, Does.ContainKey("Opc.Ua.Kept.Tests").WithValue(9));
Assert.That(weights, Does.ContainKey("Opc.Ua.Slow.Tests").WithValue(2));
Assert.That(weights, Does.ContainKey("Opc.Ua.New.Tests").WithValue(2));
using JsonDocument document = JsonDocument.Parse(await File.ReadAllTextAsync(m_tablePath).ConfigureAwait(false));
Assert.That(document.RootElement.GetProperty("defaultMinutes").GetInt32(), Is.EqualTo(5));
Assert.That(document.RootElement.GetProperty("source").GetString(), Is.EqualTo("test run"));
}
/// <summary>
/// Pointing the script at a directory without summaries is a mistake
/// (usually a missing download), not a run that observed nothing.
/// </summary>
[Test]
public async Task MissingSummariesFailWithoutTouchingTheTableAsync()
{
string before = await File.ReadAllTextAsync(m_tablePath).ConfigureAwait(false);
ScriptResult result = await RunScriptAsync().ConfigureAwait(false);
Assert.That(result.ExitCode, Is.Not.Zero);
Assert.That(result.Output, Does.Contain("No batch-summary.json"));
Assert.That(await File.ReadAllTextAsync(m_tablePath).ConfigureAwait(false), Is.EqualTo(before));
}
private async Task WriteSummaryAsync(string batch, params (string Project, string Outcome, int TestSeconds)[] records)
{
string directory = Path.Combine(m_resultsPath, $"dotnet-results-{batch}", "TestResults");
Directory.CreateDirectory(directory);
var document = new
{
projects = records.Select(record => new
{
project = record.Project,
outcome = record.Outcome,
buildSeconds = 60,
testSeconds = record.TestSeconds
}).ToArray()
};
await File.WriteAllTextAsync(
Path.Combine(directory, "batch-summary.json"),
JsonSerializer.Serialize(document)).ConfigureAwait(false);
}
private async Task<Dictionary<string, int>> ReadWeightsAsync()
{
using JsonDocument document = JsonDocument.Parse(await File.ReadAllTextAsync(m_tablePath).ConfigureAwait(false));
return document.RootElement.GetProperty("projects").EnumerateObject()
.ToDictionary(project => project.Name, project => project.Value.GetInt32(), StringComparer.Ordinal);
}
private async Task<ScriptResult> RunScriptAsync()
{
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", "update-test-durations.ps1"));
process.StartInfo.ArgumentList.Add("-ResultsPath");
process.StartInfo.ArgumentList.Add(m_resultsPath);
process.StartInfo.ArgumentList.Add("-Source");
process.StartInfo.ArgumentList.Add("test run");
process.StartInfo.ArgumentList.Add("-DurationsPath");
process.StartInfo.ArgumentList.Add(m_tablePath);
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);
return new ScriptResult(process.ExitCode, PowerShellScriptOutput.Normalize(output + error));
}
private static string FindRepositoryRoot()
{
string? current = TestContext.CurrentContext.TestDirectory;
while (!string.IsNullOrWhiteSpace(current))
{
if (File.Exists(Path.Combine(current, ".github", "scripts", "update-test-durations.ps1")))
{
return current;
}
current = Directory.GetParent(current)?.FullName;
}
throw new InvalidOperationException("Could not find the repository root.");
}
private sealed record ScriptResult(int ExitCode, string Output);
}
}
#endif