Files
UA-.NETStandard/.github/scripts/run-dotnet-tests.ps1
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

494 lines
20 KiB
PowerShell

<#
.SYNOPSIS
Builds and runs a batch of test projects for one CI profile.
.DESCRIPTION
Shared by .github/workflows/buildandtest.yml and .github/workflows/nightly.yml
through the .github/actions/run-dotnet-tests composite action. One matrix
entry carries several projects, so this script keeps per-project results: it
decides applicability, builds, tests, evaluates the emitted TRX and reports a
row per project.
Applicability is decided by evaluating the project rather than by guessing
from its path. Directory.Build.targets turns a project that opts into
RestrictForLegacyTfm into an empty no-op shell on the legacy profiles, and
marks the shell 'IsTestProject=false'. A shell is recorded as not applicable
with its reason - it is explicitly not counted as a passing test run.
A project that is applicable must produce a TRX recording at least one
executed test. A run that emits nothing, or emits a TRX with no results, is a
broken discovery rather than a pass, and fails here.
The recorded results decide the verdict, not the 'dotnet test' exit code. A
non-zero exit is tolerated when - and only when - the TRX records at least
one test and no failure, error, timeout, abort or passedButRunAborted: that
combination means the host died during process exit, after the last test and
every teardown had already run. It was originally assumed to be a macOS-only
quirk, but Windows hosts do it too (observed on run 35714133848, job
'test-windows-net48 (5/30)', where Opc.Ua.Client.Tests reported 256 passed
and 0 failed and the host still exited 1). A host that dies mid-run is not
tolerated - that leaves a non-zero counter, which fails above.
.PARAMETER Projects
Semicolon-separated, repository-relative project paths to run in order.
.PARAMETER CustomTestTarget
The value passed as /p:CustomTestTarget for build and test. This, not
'--framework', is what pins the whole stack to a single target framework
(see targets.props).
.PARAMETER Framework
The target framework the tests actually execute on.
.PARAMETER Configuration
Debug or Release.
.PARAMETER Filter
NUnit category filter. Empty runs every category, which is what the
long-running and durable tiers do.
.PARAMETER HangTimeout
--blame-hang-timeout for a single test.
.PARAMETER PerProjectTimeoutMinutes
Wall-clock ceiling for one project, shared by that project's build and test
invocations. The blame collector only reacts to inactivity inside the test
host and can itself fail to unwind, so this is the backstop that turns a
stuck run into a named per-project failure instead of an unexplained job
timeout.
It is a single combined budget on purpose. get-ci-matrix.ps1 derives each
job's `timeout-minutes` from it as `20 + projectCount * thisValue`; if the
build and the test each got the full value, a batch could burn twice that
and GitHub would cancel the job - with no per-project annotation and no
results - before the executor ever reached the ceiling it promises here.
.PARAMETER ResultsDirectory
Directory that receives one subdirectory of results per project, plus the
machine-readable batch summary.
.PARAMETER Coverage
Collect Cobertura coverage. Never set for a .NET Framework test host:
coverlet.collector ships build assets for net8.0 and newer only, so the
collector cannot load there and VSTest would only warn.
.PARAMETER QuietOutput
Write each project's build and test output to a log file under the results
directory instead of the console. Use it for inputs that must not reach a
world-readable job log: a failing fuzz test prints a base64 reproducer, so
a replay of unpublished crash inputs has to keep its output in the results
tree.
#>
Param(
[Parameter(Mandatory = $true)]
[string] $Projects,
[Parameter(Mandatory = $true)]
[string] $CustomTestTarget,
[Parameter(Mandatory = $true)]
[string] $Framework,
[Parameter(Mandatory = $true)]
[string] $Configuration,
[string] $Filter = '',
[string] $HangTimeout = '10m',
[int] $PerProjectTimeoutMinutes = 30,
[Parameter(Mandatory = $true)]
[string] $ResultsDirectory,
[switch] $Coverage,
[switch] $QuietOutput
)
$ErrorActionPreference = 'Stop'
# The verdict rule lives in its own file so it can be tested without building or
# running anything - see tests/Opc.Ua.Tools.Tests/CiTestVerdictTests.cs.
. (Join-Path $PSScriptRoot 'get-test-verdict.ps1')
$projectList = @($Projects -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
if ($projectList.Count -eq 0) {
throw 'No projects were supplied. An entry that runs nothing would report success without testing anything.'
}
$null = New-Item -ItemType Directory -Path $ResultsDirectory -Force
$resultsRoot = (Resolve-Path -LiteralPath $ResultsDirectory).Path
<#
.SYNOPSIS
Runs 'dotnet' with the supplied arguments, streaming its output to the log
and killing the process tree if the project's remaining budget runs out.
.DESCRIPTION
System.Diagnostics.Process is used instead of the call operator so the child
can be killed on timeout, and instead of Start-Process so the arguments -
which include filter expressions containing '&' and '!' - are passed through
verbatim rather than re-parsed by a shell.
The caller passes a running stopwatch covering the whole project rather than
a fresh per-invocation timeout, so the build and the test share one ceiling.
See the PerProjectTimeoutMinutes help above for why the two must not each
get the full value.
#>
function Invoke-Dotnet(
[string[]] $arguments,
[System.Diagnostics.Stopwatch] $budget,
[int] $budgetMinutes,
[string] $logPath)
{
Write-Host "dotnet $($arguments -join ' ')"
$remainingMs = ([long]$budgetMinutes * 60 * 1000) - $budget.ElapsedMilliseconds
if ($remainingMs -le 0) {
Write-Host ("::error title=CI test runner::The $budgetMinutes-minute per-project ceiling was " +
"already spent before 'dotnet $($arguments[0])' could start.")
return [pscustomobject]@{ ExitCode = -1; TimedOut = $true }
}
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = 'dotnet'
foreach ($argument in $arguments) {
$startInfo.ArgumentList.Add($argument)
}
$startInfo.UseShellExecute = $false
$writer = $null
$subscriptions = @()
if ($QuietOutput) {
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
$writer = [System.IO.StreamWriter]::new($logPath, $true)
$writer.AutoFlush = $true
}
$process = [System.Diagnostics.Process]::new()
$process.StartInfo = $startInfo
try {
if ($QuietOutput) {
# Event-driven rather than ReadToEnd on both streams in turn, which
# deadlocks as soon as one pipe buffer fills.
$handler = {
if ($null -ne $EventArgs.Data) {
$Event.MessageData.WriteLine($EventArgs.Data)
}
}
$subscriptions += Register-ObjectEvent -InputObject $process -EventName OutputDataReceived -Action $handler -MessageData $writer
$subscriptions += Register-ObjectEvent -InputObject $process -EventName ErrorDataReceived -Action $handler -MessageData $writer
}
$null = $process.Start()
if ($QuietOutput) {
$process.BeginOutputReadLine()
$process.BeginErrorReadLine()
}
if (-not $process.WaitForExit([int][System.Math]::Min([long][int]::MaxValue, $remainingMs))) {
Write-Host ("::error title=CI test runner::'dotnet $($arguments[0])' exhausted the " +
"$budgetMinutes-minute per-project ceiling and was killed.")
$process.Kill($true)
$null = $process.WaitForExit(60 * 1000)
return [pscustomobject]@{ ExitCode = -1; TimedOut = $true }
}
if ($QuietOutput) {
# The timed overload returns as soon as the process exits; only the
# parameterless one waits for the asynchronous readers to drain, and
# without it the tail of the log is lost.
$process.WaitForExit()
}
return [pscustomobject]@{ ExitCode = $process.ExitCode; TimedOut = $false }
}
finally {
foreach ($subscription in $subscriptions) {
Unregister-Event -SourceIdentifier $subscription.Name -ErrorAction SilentlyContinue
}
if ($null -ne $writer) {
$writer.Dispose()
}
$process.Dispose()
}
}
<#
.SYNOPSIS
Evaluates the properties that decide whether a project takes part in this
profile, without restoring or building it.
#>
function Get-ProjectProperties([string] $project)
{
$output = & dotnet msbuild $project `
'-getProperty:IsTestProject;TargetFrameworks;TargetFramework' `
"-p:CustomTestTarget=$CustomTestTarget" `
"-p:Configuration=$Configuration" `
-nologo | Out-String
if ($LASTEXITCODE -ne 0) {
throw "Could not evaluate '$project' for CustomTestTarget=$CustomTestTarget (exit code $LASTEXITCODE)."
}
# MSBuild prints the requested properties as a JSON document, but an
# evaluation-time warning would be printed alongside it, so take the document
# rather than the whole stream.
$start = $output.IndexOf('{')
$end = $output.LastIndexOf('}')
if ($start -lt 0 -or $end -le $start) {
throw "Evaluating '$project' produced no property document.`n$output"
}
return ($output.Substring($start, $end - $start + 1) | ConvertFrom-Json).Properties
}
<#
.SYNOPSIS
Sums the counters of every TRX below a directory.
#>
function Measure-TestResults([string] $directory)
{
$total = 0
$passed = 0
$failed = 0
$nonPassingCounters = @(
'failed',
'error',
'timeout',
'aborted',
'passedButRunAborted',
'inconclusive',
'notRunnable',
'disconnected',
'warning',
'completed',
'inProgress',
'pending')
$trxFiles = @(Get-ChildItem -LiteralPath $directory -Recurse -File -Filter *.trx -ErrorAction SilentlyContinue)
foreach ($trxFile in $trxFiles) {
# XmlDocument.Load rather than [xml](Get-Content): PubSub emits several
# megabytes of results and the array-of-lines cast fails on files that
# size.
$document = [System.Xml.XmlDocument]::new()
$document.XmlResolver = $null
$document.Load($trxFile.FullName)
foreach ($counters in $document.GetElementsByTagName('Counters')) {
$total += Get-CounterValue $counters 'total'
$passed += Get-CounterValue $counters 'passed'
foreach ($name in $nonPassingCounters) {
$failed += Get-CounterValue $counters $name
}
}
}
return [pscustomobject]@{ Files = $trxFiles.Count; Total = $total; Passed = $passed; Failed = $failed }
}
<#
.SYNOPSIS
Reads a TRX counter attribute, treating an absent attribute as zero.
#>
function Get-CounterValue([System.Xml.XmlElement] $element, [string] $name)
{
$raw = $element.GetAttribute($name)
if ([string]::IsNullOrEmpty($raw)) {
return 0
}
return [int]$raw
}
$records = @()
foreach ($project in $projectList) {
$stem = [System.IO.Path]::GetFileNameWithoutExtension($project)
Write-Host "::group::$stem ($CustomTestTarget / $Framework / $Configuration)"
$record = [ordered]@{
project = $project
customTarget = $CustomTestTarget
framework = $Framework
configuration = $Configuration
outcome = 'failed'
reason = ''
total = 0
passed = 0
failed = 0
# Wall-clock seconds, read by update-test-durations.ps1 to refresh
# the weights get-ci-matrix.ps1 packs batches with.
buildSeconds = 0
testSeconds = 0
}
try {
if (-not (Test-Path -LiteralPath $project)) {
throw "The project file does not exist."
}
$properties = Get-ProjectProperties $project
$declared = @()
if (-not [string]::IsNullOrWhiteSpace($properties.TargetFrameworks)) {
$declared = @($properties.TargetFrameworks -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
}
elseif (-not [string]::IsNullOrWhiteSpace($properties.TargetFramework)) {
$declared = @($properties.TargetFramework)
}
if ($properties.IsTestProject -eq 'false') {
# Directory.Build.targets emptied the project for this profile. The
# reason is recorded so a profile that skips everything is visible
# rather than looking like a clean run.
$record.outcome = 'not-applicable'
$record.reason = "RestrictForLegacyTfm makes this an empty shell for CustomTestTarget=$CustomTestTarget."
Write-Host "Not applicable: $($record.reason)"
continue
}
if ($declared -notcontains $Framework) {
throw ("The project builds [$($declared -join ', ')] for CustomTestTarget=$CustomTestTarget " +
"but this profile hosts its tests on $Framework. Either follow CustomTestTarget in the " +
"project's TargetFrameworks or set RestrictForLegacyTfm so the profile skips it explicitly.")
}
$projectResults = Join-Path $resultsRoot $stem
$null = New-Item -ItemType Directory -Path $projectResults -Force
$logPath = Join-Path $projectResults 'build-and-test.log'
$buildArguments = @(
'build', $project,
'--configuration', $Configuration,
'--framework', $Framework,
"/p:CustomTestTarget=$CustomTestTarget")
if ($Coverage) {
# Not what switches coverlet on - the XPlat data collector does the
# instrumentation. It turns ContinuousIntegrationBuild off in
# common.props so SourceLink does not rewrite the source paths
# embedded in the pdb to deterministic '/_/...' paths, which the
# changed-lines gate needs in order to map coverage back onto the
# working tree. It has to be set for the build, not just the test.
$buildArguments += '/p:CollectCoverage=true'
}
# One stopwatch for the build and the test together. Giving each its own
# PerProjectTimeoutMinutes would let a project burn twice the amount
# get-ci-matrix.ps1 budgeted for it in the job's timeout-minutes, so
# GitHub would cancel the whole batch - with no annotation and no
# results - instead of this executor failing that one project.
$projectBudget = [System.Diagnostics.Stopwatch]::StartNew()
$build = Invoke-Dotnet $buildArguments $projectBudget $PerProjectTimeoutMinutes $logPath
$record.buildSeconds = [int]$projectBudget.Elapsed.TotalSeconds
if ($build.TimedOut) {
throw ("The build exhausted the $PerProjectTimeoutMinutes-minute per-project ceiling, " +
'which the build and the test share.')
}
if ($build.ExitCode -ne 0) {
throw "The build failed with exit code $($build.ExitCode)."
}
$testArguments = @(
'test', $project,
'--no-build',
'--configuration', $Configuration,
'--framework', $Framework,
"/p:CustomTestTarget=$CustomTestTarget",
'--logger', 'trx',
'--results-directory', $projectResults,
# Kill (and name) any single test that outlives the profile's hang
# budget so a silent hang surfaces as "Test <Name> exceeded the
# configured timeout" rather than burning the job timeout with
# nothing to go on. The mini dump carries every thread's stack, which
# is what a teardown or shutdown hang needs, without serialising the
# managed heap of a server-hosting test process.
'--blame-hang-timeout', $HangTimeout,
'--blame-hang-dump-type', 'mini',
# Crash detection: the sequence file records which test was running
# when the host exited, and the mini dump captures the native state.
# Only written on an actual crash, so healthy runs pay no cost.
'--blame-crash',
'--blame-crash-dump-type', 'mini')
if ($Coverage) {
$testArguments += @(
'/p:CollectCoverage=true',
'--collect:XPlat Code Coverage',
'--settings', './tests/coverlet.runsettings.xml')
}
if (-not [string]::IsNullOrWhiteSpace($Filter)) {
$testArguments += @('--filter', $Filter)
}
$test = Invoke-Dotnet $testArguments $projectBudget $PerProjectTimeoutMinutes $logPath
$record.testSeconds = [int]$projectBudget.Elapsed.TotalSeconds - $record.buildSeconds
$results = Measure-TestResults $projectResults
$record.total = $results.Total
$record.passed = $results.Passed
$record.failed = $results.Failed
if ($results.Files -eq 0) {
throw ('No TRX was produced. Every mainline test project runs on VSTest and must emit one; ' +
'a Microsoft.Testing.Platform project belongs in a dedicated job instead of this matrix.')
}
$verdict = Get-TestRunVerdict `
-TrxFileCount $results.Files `
-Total $results.Total `
-Passed $results.Passed `
-Failed $results.Failed `
-ExitCode $test.ExitCode `
-TimedOut $test.TimedOut `
-TimeoutMinutes $PerProjectTimeoutMinutes
if (-not $verdict.Passed) {
throw $verdict.Reason
}
if ($verdict.Tolerated) {
# Surfaced as a warning annotation so a host that keeps dying at exit
# stays visible instead of being silently swallowed.
$record.reason = $verdict.Reason
Write-Host "::warning title=$stem ($CustomTestTarget/$Configuration)::$($verdict.Reason)"
}
$record.outcome = 'passed'
Write-Host "Passed: $($results.Passed)/$($results.Total)."
}
catch {
$record.outcome = 'failed'
$record.reason = $_.Exception.Message
Write-Host "::error title=$stem ($CustomTestTarget/$Configuration)::$($record.reason)"
}
finally {
$records += [pscustomobject]$record
Write-Host '::endgroup::'
}
}
$summaryPath = Join-Path $resultsRoot 'batch-summary.json'
[ordered]@{
customTestTarget = $CustomTestTarget
framework = $Framework
configuration = $Configuration
filter = $Filter
coverage = [bool]$Coverage
projects = $records
} | ConvertTo-Json -Depth 5 | Out-File -LiteralPath $summaryPath -Encoding utf8
$lines = @(
"### $CustomTestTarget / $Framework / $Configuration",
'',
'| Project | Outcome | Passed | Total | Build (s) | Test (s) | Detail |',
'| --- | --- | ---: | ---: | ---: | ---: | --- |')
foreach ($record in $records) {
$detail = $record.reason -replace '\r?\n', ' '
$lines += "| $([System.IO.Path]::GetFileNameWithoutExtension($record.project)) | $($record.outcome) | $($record.passed) | $($record.total) | $($record.buildSeconds) | $($record.testSeconds) | $detail |"
}
$lines += ''
if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_STEP_SUMMARY)) {
$lines | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append
}
$lines | Write-Host
$failedRecords = @($records | Where-Object { $_.outcome -eq 'failed' })
if ($failedRecords.Count -gt 0) {
throw "$($failedRecords.Count) of $($records.Count) project(s) failed: $(($failedRecords.project | ForEach-Object { [System.IO.Path]::GetFileNameWithoutExtension($_) }) -join ', ')."
}
$executed = @($records | Where-Object { $_.outcome -eq 'passed' })
if ($executed.Count -eq 0) {
# Not a failure: batches are arbitrary slices of the profile's project list,
# so one can legitimately contain only projects this profile restricts. It is
# still worth surfacing, because a batch that tests nothing is otherwise
# indistinguishable from a healthy one.
Write-Host ("::warning title=CI test runner::Every project in this batch is restricted for " +
"CustomTestTarget=$CustomTestTarget, so no tests ran.")
}
Write-Host "$($executed.Count) project(s) passed, $($records.Count - $executed.Count) not applicable."