Files
UA-.NETStandard/.github/workflows/buildandtest.yml
T
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

643 lines
28 KiB
YAML

name: CI
on:
push:
branches: [ master, main, master378, develop/*, release/* ]
pull_request:
branches: [ master, main, master378, develop/*, release/* ]
# NOTE: deliberately no `paths:` filter. A workflow that is filtered out by
# `paths` never reports its checks at all, and a required status check that
# never reports leaves a pull request permanently "Expected — waiting for
# status to be reported". Since `build-and-test summary` below is the check
# branch protection requires, this workflow has to trigger on every pull
# request. The path allow-list is applied inside the `discover` job instead,
# which skips the expensive jobs while still letting the summary report.
# Manual dispatch can opt out of the macOS runners that are enabled by default.
workflow_dispatch:
inputs:
include_macos:
description: 'Include macOS runners in the test / AoT matrix'
type: boolean
default: true
permissions:
contents: read
# This workflow owns pull-request validation. The complete build and test
# workload runs here, on GitHub-hosted runners, for every triggering branch.
# There is deliberately no switch that hands any of it back to another CI
# system: a configuration that shrinks this matrix would reduce what a pull
# request is actually tested against without changing any check name, and the
# reduction would not be visible on the pull request.
#
# The full workload - every remaining target framework, the Debug legs and the
# tiers that lift the category filter - lives in .github/workflows/nightly.yml,
# which expands the same table through the same expander.
env:
# Nerdbank.GitVersioning's SetCloudBuildVersionVars target runs once per
# project and appends the NBGV_*/Git* variables to $GITHUB_ENV. A solution
# build evaluates ~100 projects in parallel, so those appends interleave and
# tear a line, and the runner then fails the step with "Unable to process
# file command 'env' successfully. Invalid format '2.0.0'" - after a build
# that reported "0 Error(s)". It is a race, so it hits a different job on
# every run (observed on run 35708624724, build-UA net9.0 Release (linux)).
# The emission is redundant anyway: the `Set cloud version` step runs
# `nbgv cloud -c -a` once per job, which exports the same variables safely.
# Disabling it does not change any computed version - those come from
# GetBuildVersion, which this does not touch.
NBGV_SetCloudBuildVersionVars: 'false'
# Upload the merged coverage report to codecov.io. Reporting only - the
# enforced rules live in coverage-thresholds.json and are applied by
# check-coverage.ps1 in the `code-coverage` job below, so a codecov outage can
# never fail a build. Set to 'false' to turn the upload off; it is skipped
# anyway when the CODECOV_TOKEN secret is unavailable, which is the case for
# pull requests from forks.
ENABLE_CODECOV: 'true'
# Pull requests and feature branches cancel a superseded run: only the newest
# commit's result matters there. A push to a protected line (master, main,
# master378, release/*) never cancels, and each commit gets its own group,
# because a queued run in a shared group is replaced as well as a running one
# being cancelled. Every protected commit therefore finishes its required
# checks ('build-and-test summary', 'images summary'), which the Release
# ruleset demands before a release/<major>.<minor> branch can be cut from it.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || ((github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master378' || startsWith(github.ref, 'refs/heads/release/')) && github.sha) || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || !(github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master378' || startsWith(github.ref, 'refs/heads/release/')) }}
jobs:
discover:
name: Discover test projects
runs-on: ubuntu-latest
outputs:
tests: ${{ steps.matrix.outputs.tests }}
builds: ${{ steps.matrix.outputs.builds }}
batch_target_minutes: ${{ steps.matrix.outputs.batch_target_minutes }}
aot_matrix: ${{ steps.list.outputs.aot_matrix }}
relevant_changes: ${{ steps.list.outputs.relevant_changes }}
include_macos: ${{ steps.list.outputs.include_macos }}
steps:
- uses: actions/checkout@v7
with:
# Full history so the path-relevance check below can diff a pull request
# against its base commit.
fetch-depth: 0
- name: Validate migration plugin
shell: pwsh
run: ./.azurepipelines/validate-migration-plugin.ps1
- name: Resolve path relevance
id: list
shell: pwsh
env:
EVENT_NAME: ${{ github.event_name }}
INCLUDE_MACOS: ${{ github.event.inputs.include_macos }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
# macOS is included by default on pull_request, push, and schedule events
# so platform-specific failures are caught before merge. When dispatched
# manually the include_macos input can be flipped to false to skip.
$includeMac = switch ($env:EVENT_NAME) {
'workflow_dispatch' { $env:INCLUDE_MACOS -eq 'true' }
default { $true }
}
# Path relevance. This replaces the `paths:` filter that used to sit on
# the pull_request trigger: filtering the whole workflow out would stop
# `build-and-test summary` from ever reporting, and a required status
# check that never reports blocks the pull request forever. Deciding
# relevance here skips the expensive jobs instead, while the summary job
# still reports.
#
# The rule itself lives in .github/scripts/get-path-relevance.ps1 so it
# can be tested: getting it wrong in the "irrelevant" direction skips
# the build, test and AOT jobs while the required summary still reports
# success. See CiPathRelevanceTests.
$relevantChanges = $true
if ($env:EVENT_NAME -eq 'pull_request' -and -not [string]::IsNullOrWhiteSpace($env:BASE_SHA)) {
# Triple-dot: diff from the merge base, not from the current base
# branch head. A two-dot diff would also report commits that landed
# on the base branch after this one was cut, which would make an
# unrelated base-branch change look like a build-relevant change here.
$changed = @(& git diff --name-only "$($env:BASE_SHA)...HEAD")
if ($LASTEXITCODE -ne 0) {
throw (
"Could not diff pull-request base '$($env:BASE_SHA)' against HEAD " +
"(git exit code $LASTEXITCODE). Refusing to skip required CI work.")
}
$relevantChanges = [bool](& ./.github/scripts/get-path-relevance.ps1 -ChangedFile $changed)
Write-Host "Changed files: $(@($changed).Count); build-relevant: $relevantChanges"
}
$aotMatrix = @()
if ($relevantChanges) {
$aotMatrix += @{ os = 'ubuntu-latest'; rid = 'linux-x64' }
}
if ($includeMac -and $relevantChanges) {
$aotMatrix += @{ os = 'macos-15-intel'; rid = 'osx-x64' }
$aotMatrix += @{ os = 'macos-latest'; rid = 'osx-arm64' }
}
Write-Host "Event=$($env:EVENT_NAME); include_macos=$includeMac"
"relevant_changes=$($relevantChanges.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"include_macos=$($includeMac.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"aot_matrix=$(ConvertTo-Json -InputObject @($aotMatrix) -Compress)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
# The pull-request scope of the same expander the manual full-scope workflow
# uses, so both fan out from one inventory instead of two hand-maintained
# lists. Emits empty matrices when the pull request changed nothing
# build-relevant, which the jobs below test for.
- name: Expand the pull-request test and build matrix
id: matrix
shell: pwsh
env:
RELEVANT_CHANGES: ${{ steps.list.outputs.relevant_changes }}
INCLUDE_MACOS: ${{ steps.list.outputs.include_macos }}
run: |
if ($env:RELEVANT_CHANGES -ne 'true') {
Write-Host 'Nothing to expand: the pull request changed no build-relevant file.'
'tests=[]' | Out-File -FilePath $env:GITHUB_OUTPUT -Append
'builds=[]' | Out-File -FilePath $env:GITHUB_OUTPUT -Append
'batch_target_minutes=0' | Out-File -FilePath $env:GITHUB_OUTPUT -Append
exit 0
}
./.github/scripts/get-ci-matrix.ps1 -Scope pr -ExcludeMacOS:($env:INCLUDE_MACOS -ne 'true')
build-and-test:
needs: discover
name: test-${{ matrix.entry.name }}
runs-on: ${{ matrix.entry.runsOn }}
# Bound the job well below the 360-minute default. The expander sizes this
# from the batch: a hang inside the test host is named by
# --blame-hang-timeout long before it fires, and the executor kills a
# project that outlives its own ceiling, so this is only the backstop for a
# hang outside both (observed on run 30988867402, where entries sat for ~6
# hours and blocked the pull-request gate with no diagnostic information).
timeout-minutes: ${{ matrix.entry.timeoutMinutes }}
# Empty when the pull request changed nothing build-relevant; skip rather
# than fan out to nothing.
if: ${{ needs.discover.outputs.tests != '[]' }}
strategy:
fail-fast: false
matrix:
entry: ${{ fromJSON(needs.discover.outputs.tests) }}
steps:
- uses: actions/checkout@v7
with:
# Full history: Nerdbank.GitVersioning refuses to compute a version from
# a shallow clone, which fails the build before any test runs.
fetch-depth: 0
- uses: ./.github/actions/run-dotnet-tests
with:
projects: ${{ matrix.entry.projects }}
custom-test-target: ${{ matrix.entry.customTestTarget }}
framework: ${{ matrix.entry.framework }}
configuration: ${{ matrix.entry.configuration }}
dotnet: ${{ matrix.entry.dotnet }}
filter: ${{ matrix.entry.filter }}
hang-timeout: ${{ matrix.entry.hangTimeout }}
per-project-timeout: ${{ matrix.entry.perProjectTimeout }}
coverage: ${{ matrix.entry.coverage }}
# code-coverage below collects every artifact matching dotnet-results-*.
artifact-name: dotnet-results-${{ matrix.entry.id }}
code-coverage:
name: code coverage
needs: [discover, build-and-test]
runs-on: ubuntu-latest
# always(): the coverage of a run whose tests failed is still worth
# reporting. Gated on the same condition build-and-test itself uses, so a
# run that expanded no matrix does not wait for fragments that will never
# be uploaded.
if: ${{ always() && needs.discover.outputs.tests != '[]' && needs.discover.outputs.relevant_changes == 'true' }}
permissions:
contents: read
# Required to upsert the sticky coverage comment. Note that on a
# pull_request from a fork the token is read-only regardless of what this
# block says, which the comment step below handles.
pull-requests: write
env:
# The `secrets` context is not available to a step's `if`, so the presence
# of the token is resolved once here (job-level `env` can read `secrets`)
# and tested as a plain string below. Fork pull requests get no secrets,
# so this is 'false' there and the upload is skipped rather than failing.
HAS_CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN != '' }}
steps:
- uses: actions/checkout@v7
with:
# Full history: the changed-lines (patch) gate diffs against the base
# branch, which a shallow clone cannot do.
fetch-depth: 0
- name: Setup .NET
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.0.401'
- name: Download test results
uses: actions/download-artifact@v8
with:
pattern: dotnet-results-*
path: coverage-artifacts
- name: Detect coverage
id: probe
shell: pwsh
env:
TESTS_RESULT: ${{ needs.build-and-test.result }}
run: |
$reports = @(Get-ChildItem -Path coverage-artifacts -Recurse -File -Filter *.cobertura.xml -ErrorAction SilentlyContinue)
Write-Host "Found $($reports.Count) Cobertura report(s)."
"has_reports=$(($reports.Count -gt 0).ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
if ($reports.Count -gt 0) {
exit 0
}
# Nothing to evaluate. Whether that is expected depends on whether the
# test matrix actually ran: if it was skipped there is genuinely no
# coverage to collect, but a matrix that completed and published nothing
# means the collector is broken, and silently reporting green would hide
# that indefinitely.
if ($env:TESTS_RESULT -eq 'success') {
Write-Host '::error title=Code coverage::The test matrix completed but published no coverage at all; the collector is not working.'
exit 1
}
Write-Host "::warning title=Code coverage::The test matrix did not complete (result: $($env:TESTS_RESULT)), so there is no coverage to evaluate."
- name: Merge coverage report
if: ${{ steps.probe.outputs.has_reports == 'true' }}
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
dotnet tool install --tool-path tools dotnet-reportgenerator-globaltool
./tools/reportgenerator `
'-reports:coverage-artifacts/**/*.cobertura.xml' `
'-targetdir:CodeCoverage' `
'-reporttypes:Cobertura;HtmlSummary' `
'-title:UA .Net Standard Test Coverage' `
'-assemblyfilters:-*.Tests'
if ($LASTEXITCODE -ne 0) {
Write-Host "::error title=Code coverage::ReportGenerator failed with exit code $LASTEXITCODE."
exit $LASTEXITCODE
}
- name: Upload merged coverage report
if: ${{ steps.probe.outputs.has_reports == 'true' }}
uses: actions/upload-artifact@v7
with:
name: coverage-report
path: CodeCoverage
# Reporting only, and deliberately continue-on-error: the verdict comes from
# the gate below, never from codecov.io being reachable. Skipped when the
# secret is unavailable, which is how fork pull requests behave.
- name: Upload to codecov.io
if: ${{ steps.probe.outputs.has_reports == 'true' && env.ENABLE_CODECOV == 'true' && env.HAS_CODECOV_TOKEN == 'true' }}
continue-on-error: true
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: CodeCoverage/Cobertura.xml
flags: actions
disable_search: true
fail_ci_if_error: false
# Writes the markdown summary even when the gate fails, so the numbers reach
# the summary and the pull request either way. The gate's exit code is
# captured here and replayed by the last step.
- name: Evaluate coverage
id: gate
if: ${{ steps.probe.outputs.has_reports == 'true' }}
shell: pwsh
env:
# Empty on push/schedule, in which case check-coverage.ps1 enforces the
# project floor only and skips the changed-lines gate.
BASE_REF: ${{ github.base_ref }}
run: |
./.azurepipelines/check-coverage.ps1 `
-CoberturaPath CodeCoverage/Cobertura.xml `
-ThresholdsPath coverage-thresholds.json `
-RepoRoot '${{ github.workspace }}' `
-BaseRef $env:BASE_REF `
-SummaryPath coverage-summary.md
"outcome=$LASTEXITCODE" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
if (Test-Path coverage-summary.md) {
Get-Content coverage-summary.md | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append
}
exit 0
# Sticky comment: upsert a single comment keyed on an HTML marker rather
# than adding one per run. Uses actions/github-script rather than a
# community action to keep this workflow on first-party actions only.
- name: Comment coverage on the pull request
if: ${{ steps.probe.outputs.has_reports == 'true' && github.event_name == 'pull_request' }}
# A pull_request from a fork gets a read-only GITHUB_TOKEN no matter what
# `permissions:` says, so commenting fails there. The step summary still
# carries the numbers, so a failure here must not fail the job.
continue-on-error: true
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
const marker = '<!-- ua-coverage-report -->';
if (!fs.existsSync('coverage-summary.md')) {
core.info('No coverage summary to post.');
return;
}
const body = marker + '\n' + fs.readFileSync('coverage-summary.md', 'utf8');
const { owner, repo } = context.repo;
const issue_number = context.issue.number;
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number, per_page: 100,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
core.info(`Updated coverage comment ${existing.id}.`);
} else {
await github.rest.issues.createComment({ owner, repo, issue_number, body });
core.info('Created the coverage comment.');
}
- name: Report coverage gate result
if: ${{ steps.probe.outputs.has_reports == 'true' }}
shell: pwsh
run: |
if ('${{ steps.gate.outputs.outcome }}' -ne '0') {
Write-Host '::error title=Code coverage::Coverage thresholds were not met. This check is advisory and does not block the merge - see the job summary for the numbers.'
exit 1
}
Write-Host 'Coverage gate passed.'
aot-test:
needs: discover
name: aot-${{ matrix.os }}
runs-on: ${{ matrix.os }}
if: ${{ needs.discover.outputs.aot_matrix != '[]' }}
strategy:
fail-fast: false
matrix:
# OS + RID pairs computed by `discover`. Windows AoT is covered by the
# full-scope workflow, which publishes and runs all four RIDs.
include: ${{ fromJSON(needs.discover.outputs.aot_matrix) }}
env:
AOTPROJECT: './tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj'
# Companion NativeAOT test executable that hosts the distributed shared
# historian round-trip scenario. It is kept out of the monolithic
# Opc.Ua.Aot.Tests binary to bound the native linker's input. Every
# binary is published and run on every AOT platform.
AOTPROJECT_HISTORIAN: './tests/Opc.Ua.Aot.Tests.Historian/Opc.Ua.Aot.Tests.Historian.csproj'
AOTPROJECT_MCP: './tests/Opc.Ua.Aot.Tests.Mcp/Opc.Ua.Aot.Tests.Mcp.csproj'
DOTNET_VERSION: '10.0.401'
CONFIGURATION: 'Release'
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup .NET ${{ env.DOTNET_VERSION }}
uses: actions/setup-dotnet@v6
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Set Cloud Version
shell: pwsh
run: ./.azurepipelines/set-version.ps1
- name: Publish NativeAOT
run: dotnet publish ${{ env.AOTPROJECT }} --configuration ${{ env.CONFIGURATION }}
- name: Run NativeAOT Tests
run: >-
./tests/Opc.Ua.Aot.Tests/bin/${{ env.CONFIGURATION }}/net10.0/${{ matrix.rid }}/publish/Opc.Ua.Aot.Tests
--results-directory ./TestResults/aot-main
- name: Publish NativeAOT (Historian)
run: dotnet publish ${{ env.AOTPROJECT_HISTORIAN }} --configuration ${{ env.CONFIGURATION }}
- name: Run NativeAOT Tests (Historian)
run: >-
./tests/Opc.Ua.Aot.Tests.Historian/bin/${{ env.CONFIGURATION }}/net10.0/${{ matrix.rid }}/publish/Opc.Ua.Aot.Tests.Historian
--results-directory ./TestResults/aot-historian
- name: Publish NativeAOT (MCP)
run: dotnet publish ${{ env.AOTPROJECT_MCP }} --configuration ${{ env.CONFIGURATION }}
- name: Run NativeAOT Tests (MCP)
run: >-
./tests/Opc.Ua.Aot.Tests.Mcp/bin/${{ env.CONFIGURATION }}/net10.0/${{ matrix.rid }}/publish/Opc.Ua.Aot.Tests.Mcp
--results-directory ./TestResults/aot-mcp
- name: Upload test results
uses: actions/upload-artifact@v7
with:
name: aot-results-${{ matrix.os }}
path: '**/TestResults/**'
if: ${{ always() }}
- name: Upload HTML Test Report
uses: actions/upload-artifact@v7
with:
name: TestReport-aot-${{ matrix.os }}
path: '**/*-report.html'
if: ${{ always() }}
# Every solution in the repository, built for every target framework the
# runner can host, in parallel with the unit tests. Windows carries the .NET
# Framework target because it needs the .NET Framework reference assemblies;
# Linux carries the modern .NET targets. Each entry pins the solution to one
# TFM through CustomTestTarget,
# so one broken TFM is reported as its own failed job rather than hidden
# behind the first failure in a loop.
build-solutions:
name: build-${{ matrix.entry.name }}
needs: discover
if: ${{ needs.discover.outputs.builds != '[]' }}
runs-on: ${{ matrix.entry.runsOn }}
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
entry: ${{ fromJSON(needs.discover.outputs.builds) }}
steps:
- uses: actions/checkout@v7
with:
# Full history: Nerdbank.GitVersioning refuses to compute a version from
# a shallow clone.
fetch-depth: 0
- name: Setup .NET
uses: actions/setup-dotnet@v6
with:
dotnet-version: ${{ matrix.entry.dotnet }}
- name: Set cloud version
shell: pwsh
run: ./.azurepipelines/set-version.ps1
- name: Build ${{ matrix.entry.solution }} (${{ matrix.entry.customTestTarget }})
shell: pwsh
env:
CI_SOLUTION: ${{ matrix.entry.solution }}
CI_CUSTOM_TEST_TARGET: ${{ matrix.entry.customTestTarget }}
CI_CONFIGURATION: ${{ matrix.entry.configuration }}
run: |
dotnet build $env:CI_SOLUTION `
--configuration $env:CI_CONFIGURATION `
"/p:CustomTestTarget=$($env:CI_CUSTOM_TEST_TARGET)"
if ($LASTEXITCODE -ne 0) {
Write-Host "::error title=Solution build::$($env:CI_SOLUTION) failed to build for $($env:CI_CUSTOM_TEST_TARGET) ($($env:CI_CONFIGURATION))."
exit $LASTEXITCODE
}
fuzz-drop:
name: fuzz-public-drop
needs: discover
if: ${{ needs.discover.outputs.relevant_changes == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
with:
# Full history: Nerdbank.GitVersioning refuses to compute a version from
# a shallow clone, which fails the build before any test runs.
fetch-depth: 0
- uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.401
- name: Validate manifest and negative controls
shell: pwsh
run: ./fuzzing/Scripts/validate-fuzz-manifest.ps1 -SelfTest
- name: Exercise published-library contracts
shell: pwsh
run: |
$env:CustomTestTarget = 'net10.0'
$results = Join-Path $env:RUNNER_TEMP 'onefuzz-contract-results'
dotnet test ./fuzzing/OneFuzz/Opc.Ua.OneFuzz.Validator.Tests/Opc.Ua.OneFuzz.Validator.Tests.csproj `
-c Release -f net10.0 -m:1 --results-directory $results --logger 'trx;LogFileName=contracts.trx'
if ($LASTEXITCODE -ne 0) { throw "Published-library contract tests failed: $LASTEXITCODE" }
[xml]$trx = Get-Content -LiteralPath (Join-Path $results 'contracts.trx') -Raw
$counts = $trx.TestRun.ResultSummary.Counters
if ([int]$counts.executed -le 0 -or [int]$counts.failed -ne 0 -or [int]$counts.notExecuted -ne 0) {
throw 'Published-library contracts must execute without failures or skips.'
}
- name: Publish and replay uninstrumented callbacks
shell: pwsh
run: ./fuzzing/Scripts/build-onefuzz.ps1 -OutputDirectory '${{ runner.temp }}/onefuzz-drop'
- name: Upload validated public drop
uses: actions/upload-artifact@v7
with:
name: fuzz-public-drop
path: ${{ runner.temp }}/onefuzz-drop
if-no-files-found: error
fuzz-replay:
name: fuzz-replay-${{ matrix.os }}-${{ matrix.framework }}
needs: discover
if: ${{ needs.discover.outputs.relevant_changes == 'true' }}
runs-on: ${{ matrix.os }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
framework: net10.0
- os: ubuntu-latest
framework: net8.0
- os: ubuntu-latest
framework: net9.0
- os: windows-latest
framework: net48
steps:
- uses: actions/checkout@v7
with:
# Full history: Nerdbank.GitVersioning refuses to compute a version from
# a shallow clone, which fails the build before any test runs.
fetch-depth: 0
- uses: actions/setup-dotnet@v6
with:
dotnet-version: |
8.0.x
9.0.x
10.0.401
- name: Run fuzz regressions
shell: pwsh
run: ./fuzzing/Scripts/test-fuzzing.ps1 -Framework '${{ matrix.framework }}' -ResultsDirectory '${{ runner.temp }}/fuzz-results'
- name: Upload fuzz replay evidence
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: fuzz-replay-${{ matrix.os }}-${{ matrix.framework }}
path: ${{ runner.temp }}/fuzz-results
if-no-files-found: error
# Single stable status check for branch protection. The individual jobs above
# are matrix-generated (their names change with the OS/project fan-out) and
# they are skipped wholesale when a pull request touches no build-relevant
# files. A required status check that never reports would leave a PR pending
# forever, so protect on THIS job instead: this workflow has no `paths:`
# filter (which would suppress the whole run), the job is `always()`, and it
# treats an intentionally skipped job as success while still failing on a real
# failure or cancellation.
build-and-test-summary:
name: build-and-test summary
runs-on: ubuntu-latest
if: ${{ always() }}
needs:
- discover
- build-and-test
- aot-test
- build-solutions
- fuzz-replay
- fuzz-drop
steps:
- name: Evaluate job results
shell: pwsh
env:
RELEVANT_CHANGES: ${{ needs.discover.outputs.relevant_changes }}
DISCOVER_RESULT: ${{ needs.discover.result }}
TEST_RESULT: ${{ needs.build-and-test.result }}
AOT_RESULT: ${{ needs.aot-test.result }}
BUILD_RESULT: ${{ needs.build-solutions.result }}
FUZZ_RESULT: ${{ needs.fuzz-replay.result }}
FUZZ_DROP_RESULT: ${{ needs.fuzz-drop.result }}
run: |
if ($env:RELEVANT_CHANGES -eq 'true') {
Write-Host 'This change is build-relevant; the full pull-request matrix was expanded.'
}
else {
Write-Host 'No build-relevant file changed; the jobs below are expected to be skipped.'
}
$results = [ordered]@{
'discover' = $env:DISCOVER_RESULT
'build-and-test' = $env:TEST_RESULT
'aot-test' = $env:AOT_RESULT
'build-solutions' = $env:BUILD_RESULT
'fuzz-replay' = $env:FUZZ_RESULT
'fuzz-public-drop' = $env:FUZZ_DROP_RESULT
}
$bad = @()
foreach ($job in $results.GetEnumerator()) {
Write-Host "$($job.Key): $($job.Value)"
# 'skipped' is a valid outcome - the job's matrix was empty for this
# event because nothing build-relevant changed.
if (@('success', 'skipped') -notcontains $job.Value) {
$bad += "$($job.Key)=$($job.Value)"
}
}
if ($bad.Count -gt 0) {
Write-Host "::error::Build and test jobs did not succeed: $($bad -join ', ')"
exit 1
}
Write-Host 'All build and test jobs succeeded or were intentionally skipped.'