diff --git a/.azurepipelines/check-coverage.ps1 b/.azurepipelines/check-coverage.ps1
index ff62d84867..7ba33346af 100644
--- a/.azurepipelines/check-coverage.ps1
+++ b/.azurepipelines/check-coverage.ps1
@@ -3,22 +3,26 @@
Enforces the repository's code-coverage gates against a Cobertura report.
.DESCRIPTION
- Replaces the retired codecov/project and codecov/patch status checks with a
- self-contained gate that runs inside the pipeline. Three checks are made
+ The enforced coverage gate, run inside the pipeline so it can react to how
+ much a patch actually changed. codecov.io is still uploaded to, but purely
+ for reporting - its own status checks are `informational: true` (see
+ codecov.yml) precisely so there is only ever one gate. Three checks are made
against the merged Cobertura report produced by ReportGenerator:
1. Project floor (BLOCKING) - total line and branch rates must meet the
absolute floors in coverage-thresholds.json.
- 2. Patch coverage (BLOCKING) - lines added or modified relative to the pull
- request's base must reach the patch target,
- tolerating the configured threshold. This
- mirrors the old codecov/patch semantics.
+ 2. Patch coverage (GRADUATED)- lines added or modified relative to the pull
+ request's base must reach a floor that
+ scales with the size of the patch: small
+ patches warn, large ones fail. See
+ 'patch.bands' in coverage-thresholds.json.
3. Baseline delta (ADVISORY) - reports how the current total line rate
compares to the recorded master baseline.
Never fails the build.
Files matching the 'ignore' globs in coverage-thresholds.json are excluded
- from the patch calculation, exactly as they were excluded by codecov.yml.
+ from the patch calculation. Keep that list in step with the 'ignore' list in
+ codecov.yml so both report on the same code.
When no base ref is supplied - a scheduled or master build rather than a pull
request - the patch gate is skipped and only the project floor is enforced.
@@ -40,6 +44,12 @@
.PARAMETER SkipFetch
Do not run 'git fetch' for the base ref. Used by the unit tests, which
operate on a purpose-built local repository.
+
+ .PARAMETER SummaryPath
+ Optional path to write a markdown summary of the gate result to. Azure
+ Pipelines attaches it with '##vso[task.uploadsummary]' and GitHub Actions
+ appends it to $GITHUB_STEP_SUMMARY and to the pull request comment, so the
+ numbers are visible without opening the log.
#>
[CmdletBinding()]
@@ -49,7 +59,8 @@ Param(
[string] $ThresholdsPath = '',
[string] $BaseRef = '',
[string] $RepoRoot = '',
- [switch] $SkipFetch
+ [switch] $SkipFetch,
+ [string] $SummaryPath = ''
)
$ErrorActionPreference = 'Stop'
@@ -68,11 +79,16 @@ if ([string]::IsNullOrWhiteSpace($ThresholdsPath)) {
}
$script:IsAzurePipeline = -not [string]::IsNullOrEmpty($env:TF_BUILD)
+$script:IsGitHubActions = $env:GITHUB_ACTIONS -eq 'true'
function Write-GateError([string] $message) {
if ($script:IsAzurePipeline) {
Write-Host "##vso[task.logissue type=error]$message"
}
+ if ($script:IsGitHubActions) {
+ # Workflow command: surfaces the message as an annotation on the run.
+ Write-Host "::error title=Coverage gate::$message"
+ }
Write-Host "ERROR: $message"
}
@@ -80,9 +96,92 @@ function Write-GateWarning([string] $message) {
if ($script:IsAzurePipeline) {
Write-Host "##vso[task.logissue type=warning]$message"
}
+ if ($script:IsGitHubActions) {
+ Write-Host "::warning title=Coverage gate::$message"
+ }
Write-Host "WARNING: $message"
}
+<#
+.SYNOPSIS
+Selects the patch-coverage requirement that applies to a change of a given size.
+
+.DESCRIPTION
+A coverage percentage over a handful of lines carries almost no information: a
+single uncovered line in a two-line fix reads as 50%, which a flat floor would
+fail even though nothing is wrong. Small changes therefore get a lower bar and
+report a warning rather than a failure, while changes large enough for the
+percentage to mean something are enforced. Bands are consulted in order and the
+first one whose maxChangedLines covers the patch wins; anything larger falls
+through to the enforced target.
+
+.PARAMETER changedLines
+Number of coverable changed lines in the patch.
+
+.PARAMETER patch
+The 'patch' object from coverage-thresholds.json.
+#>
+function Get-PatchBand([int] $changedLines, $patch) {
+ $bands = @($patch.bands)
+ foreach ($band in $bands) {
+ if ($null -eq $band) { continue }
+ if ($changedLines -le [int]$band.maxChangedLines) {
+ return [pscustomobject]@{
+ Floor = [double]$band.target
+ Enforced = [bool]$band.enforced
+ Scope = ('<= {0} changed lines' -f [int]$band.maxChangedLines)
+ }
+ }
+ }
+
+ $largest = if ($bands.Count -gt 0) { [int]$bands[-1].maxChangedLines } else { 0 }
+ return [pscustomobject]@{
+ Floor = [double]$patch.target - [double]$patch.threshold
+ Enforced = $true
+ Scope = ('> {0} changed lines' -f $largest)
+ }
+}
+
+# Markdown summary accumulated as the gate runs and written to -SummaryPath at
+# the end. Kept separate from the console output because the console log is a
+# flat transcript while this is rendered as a table in both CI UIs.
+$script:SummaryRows = [System.Collections.Generic.List[string]]::new()
+$script:SummaryNotes = [System.Collections.Generic.List[string]]::new()
+
+<#
+ .SYNOPSIS
+ Adds a row to the markdown summary table.
+
+ .PARAMETER check
+ Name of the check, for example 'Project line rate'.
+
+ .PARAMETER value
+ The measured value, already formatted.
+
+ .PARAMETER target
+ The threshold the value is compared against, or '-' when there is none.
+
+ .PARAMETER state
+ One of 'pass', 'fail', 'warn' or 'info'.
+#>
+function Add-SummaryRow([string] $check, [string] $value, [string] $target, [string] $state) {
+ $icon = switch ($state) {
+ 'pass' { ':white_check_mark:' }
+ 'fail' { ':x:' }
+ 'warn' { ':warning:' }
+ default { ':information_source:' }
+ }
+ $script:SummaryRows.Add(('| {0} {1} | {2} | {3} |' -f $icon, $check, $value, $target))
+}
+
+<#
+ .SYNOPSIS
+ Adds a free-form markdown note below the summary table.
+#>
+function Add-SummaryNote([string] $note) {
+ $script:SummaryNotes.Add($note)
+}
+
<#
.SYNOPSIS
Converts a repository-relative glob into an anchored regular expression.
@@ -427,19 +526,29 @@ $failures = @()
$minLine = [double]$thresholds.project.minimumLineRate
if ($null -eq $totals.LineRate) {
$failures += 'No coverable lines were found in the report; the run did not produce usable coverage.'
+ Add-SummaryRow 'Project line rate' 'no data' ('>= {0:N2}%' -f $minLine) 'fail'
}
elseif ($totals.LineRate -lt $minLine) {
$failures += ('Total line coverage {0:N2}% is below the required floor of {1:N2}%.' -f $totals.LineRate, $minLine)
+ Add-SummaryRow 'Project line rate' ('**{0:N2}%** ({1}/{2} lines)' -f $totals.LineRate, $totals.CoveredLines, $totals.TotalLines) ('>= {0:N2}%' -f $minLine) 'fail'
+}
+else {
+ Add-SummaryRow 'Project line rate' ('**{0:N2}%** ({1}/{2} lines)' -f $totals.LineRate, $totals.CoveredLines, $totals.TotalLines) ('>= {0:N2}%' -f $minLine) 'pass'
}
$minBranch = [double]$thresholds.project.minimumBranchRate
if ($null -ne $totals.BranchRate -and $totals.BranchRate -lt $minBranch) {
$failures += ('Total branch coverage {0:N2}% is below the required floor of {1:N2}%.' -f $totals.BranchRate, $minBranch)
+ Add-SummaryRow 'Project branch rate' ('**{0:N2}%**' -f $totals.BranchRate) ('>= {0:N2}%' -f $minBranch) 'fail'
+}
+elseif ($null -ne $totals.BranchRate) {
+ Add-SummaryRow 'Project branch rate' ('**{0:N2}%**' -f $totals.BranchRate) ('>= {0:N2}%' -f $minBranch) 'pass'
}
# 2. Patch coverage (blocking, pull requests only).
if ([string]::IsNullOrWhiteSpace($BaseRef)) {
Write-Host 'No base ref supplied; skipping the changed-lines (patch) gate.'
+ Add-SummaryRow 'Patch coverage' 'not a pull request' '-' 'info'
}
else {
$changed = Get-ChangedLines -baseRef $BaseRef -repoRoot $RepoRoot -skipFetch:$SkipFetch.IsPresent
@@ -463,14 +572,17 @@ else {
if ($uncovered.Count -gt 0) { $uncoveredByFile[$file] = $uncovered }
}
- $patchFloor = [double]$thresholds.patch.target - [double]$thresholds.patch.threshold
+ $patchBand = Get-PatchBand -changedLines $coverableChanged -patch $thresholds.patch
+ $patchFloor = $patchBand.Floor
if ($coverableChanged -eq 0) {
Write-Host 'No coverable changed lines were found; the patch gate passes vacuously.'
+ Add-SummaryRow 'Patch coverage' 'no coverable changed lines' '-' 'info'
}
else {
$patchRate = 100.0 * $coveredChanged / $coverableChanged
- Write-Host ("Patch: {0:N2}% ({1}/{2} changed lines covered, floor {3:N2}%)" -f `
- $patchRate, $coveredChanged, $coverableChanged, $patchFloor)
+ Write-Host ("Patch: {0:N2}% ({1}/{2} changed lines covered, floor {3:N2}% for {4}, {5})" -f `
+ $patchRate, $coveredChanged, $coverableChanged, $patchFloor, $patchBand.Scope,
+ $(if ($patchBand.Enforced) { 'enforced' } else { 'advisory' }))
if ($uncoveredByFile.Count -gt 0) {
Write-Host 'Uncovered changed lines:'
@@ -479,9 +591,46 @@ else {
}
}
- if ($patchRate -lt $patchFloor) {
- $failures += ('Patch coverage {0:N2}% is below the required {1:N2}% ({2} of {3} changed lines are uncovered).' -f `
- $patchRate, $patchFloor, ($coverableChanged - $coveredChanged), $coverableChanged)
+ $patchBelowFloor = $patchRate -lt $patchFloor
+ $patchState = if (-not $patchBelowFloor) {
+ 'pass'
+ }
+ elseif ($patchBand.Enforced) {
+ 'fail'
+ }
+ else {
+ 'warn'
+ }
+
+ Add-SummaryRow 'Patch coverage' `
+ ('**{0:N2}%** ({1}/{2} changed lines)' -f $patchRate, $coveredChanged, $coverableChanged) `
+ ('>= {0:N2}% ({1}{2})' -f $patchFloor, $patchBand.Scope,
+ $(if ($patchBand.Enforced) { '' } else { ', advisory' })) `
+ $patchState
+
+ # List the uncovered changed lines in the summary too - that is the
+ # actionable part for the author, and it saves opening the raw log.
+ if ($uncoveredByFile.Count -gt 0) {
+ $detail = [System.Text.StringBuilder]::new()
+ $null = $detail.AppendLine('Uncovered changed lines')
+ $null = $detail.AppendLine('')
+ foreach ($file in $uncoveredByFile.Keys) {
+ $null = $detail.AppendLine(('- `{0}`: {1}' -f $file, ($uncoveredByFile[$file] -join ', ')))
+ }
+ $null = $detail.AppendLine('')
+ $null = $detail.Append('')
+ Add-SummaryNote $detail.ToString()
+ }
+
+ if ($patchBelowFloor) {
+ $message = ('Patch coverage {0:N2}% is below {1:N2}% for {2} ({3} of {4} changed lines are uncovered).' -f `
+ $patchRate, $patchFloor, $patchBand.Scope, ($coverableChanged - $coveredChanged), $coverableChanged)
+ if ($patchBand.Enforced) {
+ $failures += $message
+ }
+ else {
+ Write-GateWarning ($message + ' Advisory at this patch size - add a test if the change deserves one.')
+ }
}
}
}
@@ -492,17 +641,68 @@ $tolerance = [double]$thresholds.project.advisoryDeltaTolerance
if ($null -ne $totals.LineRate -and $baseline -gt 0) {
$delta = $totals.LineRate - $baseline
Write-Host ("Baseline: {0:N2}% recorded, delta {1:+0.00;-0.00;0.00} percentage points" -f $baseline, $delta)
+ $deltaText = '{0:+0.00;-0.00;0.00} pp' -f $delta
if ($delta -lt (-1 * $tolerance)) {
Write-GateWarning ('Total line coverage dropped {0:N2} percentage points below the recorded master baseline of {1:N2}%. This is advisory and does not fail the build.' -f `
[Math]::Abs($delta), $baseline)
+ Add-SummaryRow 'Baseline delta (advisory)' $deltaText ('{0:N2}% recorded' -f $baseline) 'warn'
}
- elseif ($delta -gt $tolerance) {
- Write-Host 'Coverage is above the recorded baseline; consider ratcheting coverage-thresholds.json.'
+ else {
+ if ($delta -gt $tolerance) {
+ Write-Host 'Coverage is above the recorded baseline; consider ratcheting coverage-thresholds.json.'
+ Add-SummaryNote 'Coverage is above the recorded baseline - consider ratcheting `coverage-thresholds.json`.'
+ }
+ Add-SummaryRow 'Baseline delta (advisory)' $deltaText ('{0:N2}% recorded' -f $baseline) 'info'
}
}
if ($failures.Count -gt 0) {
foreach ($failure in $failures) { Write-GateError $failure }
+}
+
+if (-not [string]::IsNullOrWhiteSpace($SummaryPath)) {
+ $verdict = if ($failures.Count -gt 0) {
+ ':x: **Coverage gate failed.** This check is advisory and does not block the merge.'
+ }
+ else {
+ ':white_check_mark: **Coverage gate passed.**'
+ }
+
+ $summary = [System.Text.StringBuilder]::new()
+ $null = $summary.AppendLine('## Code coverage')
+ $null = $summary.AppendLine('')
+ $null = $summary.AppendLine($verdict)
+ $null = $summary.AppendLine('')
+ $null = $summary.AppendLine('| Check | Result | Threshold |')
+ $null = $summary.AppendLine('| --- | --- | --- |')
+ foreach ($row in $script:SummaryRows) {
+ $null = $summary.AppendLine($row)
+ }
+ if ($failures.Count -gt 0) {
+ $null = $summary.AppendLine('')
+ foreach ($failure in $failures) {
+ $null = $summary.AppendLine(('- :x: {0}' -f $failure))
+ }
+ }
+ foreach ($note in $script:SummaryNotes) {
+ $null = $summary.AppendLine('')
+ $null = $summary.AppendLine($note)
+ }
+ $null = $summary.AppendLine('')
+ $null = $summary.AppendLine(('Thresholds live in `coverage-thresholds.json`. Whole report before exclusions: line {0:N2}%, branch {1:N2}%.' -f `
+ $report.ReportLineRate, $report.ReportBranchRate))
+
+ $summaryDir = Split-Path -Parent $SummaryPath
+ if (-not [string]::IsNullOrWhiteSpace($summaryDir) -and -not (Test-Path $summaryDir)) {
+ $null = New-Item -ItemType Directory -Force -Path $summaryDir
+ }
+ # UTF8 without BOM: GitHub renders a leading BOM as a literal character at
+ # the top of the step summary.
+ [System.IO.File]::WriteAllText($SummaryPath, $summary.ToString(), [System.Text.UTF8Encoding]::new($false))
+ Write-Host "Wrote the markdown summary to $SummaryPath."
+}
+
+if ($failures.Count -gt 0) {
Write-Host 'Coverage gate FAILED.'
exit 1
}
diff --git a/.azurepipelines/coverage.yml b/.azurepipelines/coverage.yml
new file mode 100644
index 0000000000..e1a108b736
--- /dev/null
+++ b/.azurepipelines/coverage.yml
@@ -0,0 +1,259 @@
+#
+# Advisory code coverage check.
+#
+# Collects the raw Cobertura fragments every test job of the run published,
+# merges them once with ReportGenerator and evaluates coverage-thresholds.json
+# against the merged report.
+#
+# This check is ADVISORY: it reports a clean failure when the thresholds are
+# missed so the miss is visible, but it must NOT be added to the GitHub branch
+# ruleset. The required check is the 'Tests passed' stage in azure-pipelines.yml.
+#
+# It deliberately does not re-run any tests. The previous design re-ran the
+# whole suite in a single job with the collector attached, which serialised a
+# suite that is deliberately fanned out across matrix jobs and always exceeded
+# the stage timeout.
+#
+parameters:
+# Stages to depend on. Their results decide whether missing coverage is
+# expected (nothing ran) or a real problem (a stage ran but published nothing).
+- name: stages
+ type: object
+ default: []
+- name: stagename
+ type: string
+ default: 'coverage'
+- name: poolName
+ type: string
+ default: ''
+# The check only merges XML and diffs git, so it is independent of any framework
+# under test and always runs on Linux.
+- name: poolImage
+ type: string
+ default: 'ubuntu-24.04'
+# Publish the merged report to the build's Code Coverage tab and to Codacy.
+- name: publishCoverage
+ type: boolean
+ default: true
+# Upload the merged report to codecov.io. Reporting only - the enforced rules
+# live in coverage-thresholds.json and are applied by the gate below. Set to
+# false to turn the upload off entirely; it is skipped anyway when the
+# CODECOV_TOKEN secret variable is not set, which is the case on fork pull
+# requests.
+- name: enableCodecov
+ type: boolean
+ default: true
+stages:
+- stage: ${{ parameters.stagename }}
+ displayName: 'Code coverage'
+ dependsOn: ${{ parameters.stages }}
+ # Evaluate whatever coverage the run did produce even when a test stage
+ # failed: a partial report is still worth publishing, and the 'Tests passed'
+ # stage already carries the failure signal.
+ condition: not(canceled())
+ jobs:
+ - job: coverage
+ displayName: Merge and evaluate
+ timeoutInMinutes: 30
+ variables:
+ DOTNET_CLI_TELEMETRY_OPTOUT: true
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
+ disable.coverage.autogenerate: true
+ pool:
+ ${{ if ne(parameters.poolName, '') }}:
+ name: ${{ parameters.poolName }}
+ demands:
+ - ImageOverride -equals ${{ parameters.poolImage }}
+ ${{ else }}:
+ vmImage: ${{ parameters.poolImage }}
+ steps:
+ # Full history: the changed-lines (patch) check diffs the pull request
+ # against its base branch, which a shallow clone cannot do.
+ - checkout: self
+ fetchDepth: 0
+ - task: UseDotNet@2
+ displayName: 'Install .NET 10.0'
+ inputs:
+ packageType: 'sdk'
+ version: '10.0.x'
+ - task: DownloadPipelineArtifact@2
+ displayName: 'Download code coverage'
+ inputs:
+ buildType: current
+ # Every test job of every stage publishes 'coverage--'.
+ patterns: 'coverage-*/**/*.cobertura.xml'
+ path: '$(Agent.TempDirectory)/coverage'
+ continueOnError: true
+ - task: PowerShell@2
+ name: reportProbe
+ displayName: 'Detect code coverage'
+ inputs:
+ pwsh: true
+ targetType: inline
+ script: |
+ $source = '$(Agent.TempDirectory)/coverage'
+ $artifacts = @(Get-ChildItem -Path $source -Directory -ErrorAction SilentlyContinue)
+ $reports = @(Get-ChildItem -Path $source -Recurse -File -Filter *.cobertura.xml -ErrorAction SilentlyContinue)
+ Write-Host "Downloaded $($reports.Count) Cobertura report(s) from $($artifacts.Count) test job(s)."
+ $found = $reports.Count -gt 0
+ Write-Host "##vso[task.setvariable variable=HAS_REPORTS;isOutput=true]$($found.ToString().ToLowerInvariant())"
+ if ($found) {
+ exit 0
+ }
+
+ # Nothing to evaluate. That is expected when every test stage was
+ # skipped or failed before publishing anything, and it is the 'Tests
+ # passed' stage that owns the failure signal for those, so warn rather
+ # than manufacture a red coverage check with no data behind it.
+ #
+ # A broken collector on an otherwise green run is caught on the
+ # GitHub Actions side instead, where the test matrix result is
+ # available cheaply; the equivalent here would need the stage
+ # dependency context, which is too large to serialise in an
+ # expression (see the 'Tests passed' stage in azure-pipelines.yml).
+ Write-Host "##vso[task.logissue type=warning]No coverage was published by any test job; there is nothing to evaluate."
+ exit 0
+ - task: DotNetCoreCLI@2
+ displayName: Install ReportGenerator tool
+ condition: and(succeeded(), eq(variables['reportProbe.HAS_REPORTS'], 'true'))
+ inputs:
+ command: custom
+ custom: tool
+ arguments: install --tool-path tools dotnet-reportgenerator-globaltool
+ - task: PowerShell@2
+ displayName: 'Merge Code Coverage Report'
+ condition: and(succeeded(), eq(variables['reportProbe.HAS_REPORTS'], 'true'))
+ inputs:
+ pwsh: true
+ targetType: inline
+ workingDirectory: '$(Build.SourcesDirectory)'
+ script: |
+ $ErrorActionPreference = 'Stop'
+ $source = '$(Agent.TempDirectory)/coverage'
+ & '$(Build.SourcesDirectory)/tools/reportgenerator' `
+ "-reports:$source/**/*.cobertura.xml" `
+ '-targetdir:$(Build.SourcesDirectory)/CodeCoverage' `
+ '-reporttypes:Cobertura;HtmlSummary' `
+ '-title:UA .Net Standard Test Coverage' `
+ '-assemblyfilters:-*.Tests'
+ if ($LASTEXITCODE -ne 0) {
+ Write-Host "##vso[task.logissue type=error]ReportGenerator failed with exit code $LASTEXITCODE."
+ exit $LASTEXITCODE
+ }
+ $summary = '$(Build.SourcesDirectory)/CodeCoverage/summary.htm'
+ if (Test-Path $summary) {
+ Move-Item -Force $summary '$(Build.SourcesDirectory)/CodeCoverage/index.htm'
+ }
+ - task: PublishPipelineArtifact@1
+ displayName: 'Publish merged coverage report'
+ condition: and(succeeded(), eq(variables['reportProbe.HAS_REPORTS'], 'true'))
+ continueOnError: true
+ inputs:
+ targetPath: '$(Build.SourcesDirectory)/CodeCoverage'
+ artifact: 'coverage-report'
+ - ${{ if parameters.publishCoverage }}:
+ - task: PublishCodeCoverageResults@2
+ displayName: 'Publish code coverage'
+ condition: and(succeeded(), eq(variables['reportProbe.HAS_REPORTS'], 'true'))
+ continueOnError: true
+ inputs:
+ summaryFileLocation: '$(Build.SourcesDirectory)/CodeCoverage/Cobertura.xml'
+ - script: |
+ bash <(curl -Ls https://coverage.codacy.com/get.sh) report -r $REPORT_LOCATION --commit-uuid $COMMIT_UUID
+
+ condition: and(succeeded(), eq(variables['reportProbe.HAS_REPORTS'], 'true'), ne(variables['CODACY_PROJECT_TOKEN'], ''))
+ env:
+ CODACY_PROJECT_TOKEN: $(CODACY_PROJECT_TOKEN)
+ REPORT_LOCATION: '$(Build.SourcesDirectory)/CodeCoverage/Cobertura.xml'
+ COMMIT_UUID: '$(Build.SourceVersion)'
+ continueOnError: true
+ displayName: 'Upload to Codacy'
+ - ${{ if parameters.enableCodecov }}:
+ - script: |
+ set -euo pipefail
+ # The bash uploader (codecov.io/bash) was sunset; this is the current
+ # Codecov CLI. Fetched as the platform binary rather than via pip so
+ # the step does not depend on a Python toolchain being present.
+ curl -Os https://cli.codecov.io/latest/linux/codecov
+ chmod +x codecov
+ # On a pull request validation build Build.SourceVersion is the
+ # *merge* commit Azure created, which Codecov cannot match to the pull
+ # request. Report the head of the source branch instead when there is
+ # one.
+ SHA="${PR_SHA:-}"
+ if [ -z "$SHA" ]; then
+ SHA="$BUILD_SHA"
+ fi
+ # Resolve owner/repo. Build.Repository.Name is empty on this pipeline
+ # (Azure puts the slug in Build.Repository.ID for a GitHub-backed
+ # repository), so fall back to parsing the clone URI, which is always
+ # populated. Passing an empty slug is what made the CLI report
+ # "Repository not found".
+ SLUG="${REPO_SLUG:-}"
+ if [ -z "$SLUG" ]; then
+ SLUG=$(printf '%s' "$REPO_URI" | sed -E 's#^.*://[^/]+/##; s#\.git$##')
+ fi
+ echo "Uploading coverage for slug '$SLUG' at commit '$SHA'."
+ ./codecov upload-process \
+ --disable-search \
+ --file "$REPORT_LOCATION" \
+ --flag azure \
+ --git-service github \
+ --slug "$SLUG" \
+ --sha "$SHA"
+ # continueOnError: reporting must never be able to fail the run. The
+ # verdict comes from the gate below, not from codecov.io being
+ # reachable.
+ continueOnError: true
+ condition: >-
+ and(succeeded(),
+ eq(variables['reportProbe.HAS_REPORTS'], 'true'),
+ ne(variables['CODECOV_TOKEN'], ''))
+ env:
+ CODECOV_TOKEN: $(CODECOV_TOKEN)
+ REPORT_LOCATION: '$(Build.SourcesDirectory)/CodeCoverage/Cobertura.xml'
+ BUILD_SHA: '$(Build.SourceVersion)'
+ PR_SHA: '$(System.PullRequest.SourceCommitId)'
+ # The CLI cannot infer owner/repo from an Azure DevOps pipeline that
+ # builds a GitHub repository - it looks for an Azure Repos project and
+ # reports "Repository not found".
+ REPO_SLUG: '$(Build.Repository.ID)'
+ REPO_URI: '$(Build.Repository.Uri)'
+ displayName: 'Upload to codecov.io'
+ # Runs last so the report is published and downloadable even when the
+ # thresholds are missed.
+ - task: PowerShell@2
+ displayName: 'Coverage gate'
+ condition: and(succeeded(), eq(variables['reportProbe.HAS_REPORTS'], 'true'))
+ inputs:
+ pwsh: true
+ targetType: inline
+ workingDirectory: '$(Build.SourcesDirectory)'
+ script: |
+ # Empty on anything that is not a pull request validation build, in
+ # which case check-coverage.ps1 checks the project floor only.
+ $baseRef = $env:SYSTEM_PULLREQUEST_TARGETBRANCH
+ if ([string]::IsNullOrWhiteSpace($baseRef)) {
+ Write-Host 'Not a pull request build; the changed-lines check is skipped.'
+ }
+ else {
+ Write-Host "Pull request targets '$baseRef'."
+ }
+
+ $summaryPath = '$(Agent.TempDirectory)/coverage-summary.md'
+ & ./.azurepipelines/check-coverage.ps1 `
+ -CoberturaPath '$(Build.SourcesDirectory)/CodeCoverage/Cobertura.xml' `
+ -ThresholdsPath '$(Build.SourcesDirectory)/coverage-thresholds.json' `
+ -RepoRoot '$(Build.SourcesDirectory)' `
+ -BaseRef $baseRef `
+ -SummaryPath $summaryPath
+ $gate = $LASTEXITCODE
+
+ # Attach the markdown to the build summary so the numbers are visible
+ # without opening this log.
+ if (Test-Path $summaryPath) {
+ Write-Host "##vso[task.uploadsummary]$summaryPath"
+ }
+
+ exit $gate
+
diff --git a/.azurepipelines/get-matrix.ps1 b/.azurepipelines/get-matrix.ps1
index 31462297ff..24f2f0cfd3 100644
--- a/.azurepipelines/get-matrix.ps1
+++ b/.azurepipelines/get-matrix.ps1
@@ -48,6 +48,12 @@
the matrix is fanned out across TFMs; each entry carries a
'targetTfm' variable that downstream jobs typically pass to
'dotnet build' as '/p:CustomTestTarget=$(targetTfm)'.
+
+ .PARAMETER AllowEmpty
+ Do not fail when discovery produces no matrix entries. Off by default,
+ because an empty matrix skips the downstream job, which rolls up as a
+ successful stage and would let the 'Tests passed' gate approve a run that
+ executed no tests.
#>
Param(
@@ -58,7 +64,8 @@ Param(
[hashtable] $AgentTable = $null,
[string] $Configurations = '',
[string] $Files = '',
- [string] $Tfms = ''
+ [string] $Tfms = '',
+ [switch] $AllowEmpty
)
if ([string]::IsNullOrEmpty($BuildRoot)) {
@@ -204,5 +211,15 @@ foreach ($item in $items) {
}
Write-Host ("Job matrix:`n" + ($jobMatrix | ConvertTo-Json -Depth 4))
+
+# An empty matrix silently produces a stage whose only real job is skipped, and
+# a skipped job rolls up as a *successful* stage - so a broken discovery pattern
+# would sail through the 'Tests passed' gate having run nothing at all. Fail
+# loudly instead; no caller legitimately expects zero matches.
+if ($jobMatrix.Count -eq 0 -and -not $AllowEmpty) {
+ Write-Host "##vso[task.logissue type=error]The job matrix is empty - no file matched '$FileName' beneath '$BuildRoot'. Pass -AllowEmpty if that is genuinely expected."
+ exit 1
+}
+
Write-Host ("##vso[task.setVariable variable=jobMatrix;isOutput=true] {0}" `
-f ($jobMatrix | ConvertTo-Json -Compress))
diff --git a/.azurepipelines/test.yml b/.azurepipelines/test.yml
index b2d3b8926c..561a9d467a 100644
--- a/.azurepipelines/test.yml
+++ b/.azurepipelines/test.yml
@@ -53,51 +53,28 @@ parameters:
- name: windowsImage
type: string
default: 'windows-2025-vs2026'
-# Optional job-level condition forwarded to the testprep…, testall… and
-# coverage… jobs.
+# Optional job-level condition forwarded to the testprep… and testall… jobs.
# When unset (the overwhelming majority of callers) no `condition:` line is
-# emitted - preserves today's "always run" behaviour exactly. When set, all
+# emitted - preserves today's "always run" behaviour exactly. When set, both
# jobs receive the same condition so the entire test invocation is gated
# together (used by the root pipeline to skip macOS test invocations on draft
# GitHub PRs). Pass the condition *bare*, without a succeeded() wrapper: the
-# test jobs wrap it in succeeded() so a build cancellation still propagates to
-# them - without the wrapper an Azure Pipelines `condition:` re-evaluates to
-# true during cancel and the job keeps running (see "Job will continue running
-# after cancellation was requested because its condition re-evaluated to true"
-# warnings) - while the coverage gate wraps it in not(canceled()) because it has
-# to report a failed status check when the tests failed.
+# jobs wrap it in succeeded() so a build cancellation still propagates to them -
+# without the wrapper an Azure Pipelines `condition:` re-evaluates to true during
+# cancel and the job keeps running (see "Job will continue running after
+# cancellation was requested because its condition re-evaluated to true"
+# warnings).
- name: condition
type: string
default: ''
-# Collect code coverage in every test job of this invocation and append a
-# 'Coverage ...' job that merges the per-project Cobertura reports published by
-# those jobs and runs check-coverage.ps1 against the merged result. The gate
-# job is the single per-(framework, configuration) signal used as the required
-# GitHub status check: it fails when any test job failed, and when the merged
-# coverage misses the thresholds, so the tests never have to be re-run.
+# Collect code coverage in every test job of this invocation and publish each
+# matrix entry's raw Cobertura fragment as a pipeline artifact. The 'Code
+# coverage' stage (.azurepipelines/coverage.yml) collects every fragment the run
+# produced, merges them once and evaluates the thresholds - this template only
+# produces the inputs, it does not gate anything.
- name: coverage
type: boolean
default: true
-# Blocking (true) vs advisory (false) coverage thresholds. Test failures always
-# block regardless of this switch.
-- name: enforceCoverage
- type: boolean
- default: false
-# Publish the merged report to the build's Code Coverage tab and to Codacy.
-# Exactly one invocation per pipeline run may set this - Azure DevOps keeps a
-# single code-coverage summary per build, so a second publish overwrites the
-# first.
-- name: publishCoverage
- type: boolean
- default: false
-# Pool for the coverage gate job. The gate only merges XML and diffs git, so it
-# is deliberately decoupled from the framework under test and runs on Linux.
-- name: coveragePoolName
- type: string
- default: ''
-- name: coverageImage
- type: string
- default: 'ubuntu-24.04'
jobs:
- job: testprep${{ parameters.jobnamesuffix }}
displayName: Prepare Test Jobs ${{ parameters.configuration }} (${{ parameters.framework }})
@@ -348,12 +325,12 @@ jobs:
}
Write-Host "No test failures recorded across $total test(s); treating the run as successful (tolerating any non-zero test-host exit after a fully green run)."
exit 0
- # Hand the raw per-project Cobertura report to the Coverage gate job below
- # instead of letting that job re-run the whole suite with coverage enabled.
+ # Hand the raw per-project Cobertura report to the 'Code coverage' stage
+ # instead of letting a gate job re-run the whole suite with coverage enabled.
# Re-running defeats the point of fanning the suite out across matrix jobs and
# reliably blew the stage timeout. The artifact name is unique per matrix
- # entry (System.JobId) and prefixed with this invocation's job-name suffix so
- # the gate job downloads only the reports for its own framework/configuration.
+ # entry (System.JobId); the coverage stage collects 'coverage-*' across every
+ # stage of the run and merges them once.
- ${{ if and(parameters.coverage, not(startsWith(parameters.framework, 'net4')), not(startsWith(parameters.customtestarget, 'net4'))) }}:
- task: CopyFiles@2
displayName: 'Stage code coverage'
@@ -446,209 +423,3 @@ jobs:
inputs:
targetPath: '$(Build.ArtifactStagingDirectory)/dumps'
artifact: 'dumps-$(System.JobId)'
-# Per-(framework, configuration) gate. Runs once every test job of this
-# invocation has finished, re-assembles the Cobertura fragments those jobs
-# published into a single report and evaluates the coverage thresholds on it.
-# This job - not the fan-out matrix - is the status check to require in the
-# GitHub branch ruleset: it fails when any test job failed and, on the leg that
-# sets enforceCoverage, when the merged report is missing or misses the
-# thresholds in coverage-thresholds.json.
-- ${{ if parameters.coverage }}:
- - job: coverage${{ parameters.jobnamesuffix }}
- displayName: Coverage ${{ parameters.configuration }} (${{ parameters.framework }})
- dependsOn:
- - testprep${{ parameters.jobnamesuffix }}
- - testall${{ parameters.jobnamesuffix }}
- timeoutInMinutes: 30
- # not(canceled()) rather than the default succeeded(): the job has to report
- # a *failed* status check when the tests failed, and a job that is skipped
- # because its dependency failed surfaces to GitHub as 'skipped', which a
- # required check treats as satisfied.
- ${{ if ne(parameters.condition, '') }}:
- condition: and(not(canceled()), ${{ parameters.condition }})
- ${{ else }}:
- condition: not(canceled())
- variables:
- DOTNET_CLI_TELEMETRY_OPTOUT: true
- DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
- disable.coverage.autogenerate: true
- testsResult: $[ dependencies.testall${{ parameters.jobnamesuffix }}.result ]
- testMatrix: $[ dependencies.testprep${{ parameters.jobnamesuffix }}.outputs['testmatrix.jobMatrix'] ]
- # The gate merges XML and diffs git, so it is independent of the framework
- # under test and always runs on Linux.
- pool:
- ${{ if ne(parameters.coveragePoolName, '') }}:
- name: ${{ parameters.coveragePoolName }}
- demands:
- - ImageOverride -equals ${{ parameters.coverageImage }}
- ${{ else }}:
- vmImage: ${{ parameters.coverageImage }}
- steps:
- # Full history: the coverage gate diffs the pull request against its base branch
- # to compute changed-line (patch) coverage, which a shallow clone cannot do.
- - checkout: self
- fetchDepth: 0
- - task: PowerShell@2
- displayName: 'Verify test results ${{ parameters.configuration }} (${{ parameters.framework }})'
- inputs:
- pwsh: true
- targetType: inline
- script: |
- # Aggregated result of every matrix entry of the test job. Anything
- # other than a (possibly warning-decorated) success means at least one
- # test job failed, was cancelled or never ran - fail here so this
- # single check reflects the health of the whole framework leg.
- $result = '$(testsResult)'
- Write-Host "Test jobs for ${{ parameters.framework }} ${{ parameters.configuration }} reported '$result'."
- if ($result -eq 'Succeeded' -or $result -eq 'SucceededWithIssues') {
- exit 0
- }
- Write-Host "##vso[task.logissue type=error]Tests for ${{ parameters.framework }} ${{ parameters.configuration }} did not succeed (result: $result)."
- exit 1
- - task: UseDotNet@2
- displayName: 'Install .NET 10.0'
- inputs:
- packageType: 'sdk'
- version: '10.0.x'
- - task: DownloadPipelineArtifact@2
- displayName: 'Download code coverage'
- inputs:
- buildType: current
- # One artifact per matrix entry; the '-' after the suffix keeps sibling
- # invocations apart (for example net100 does not pick up net100mac).
- patterns: 'coverage-${{ parameters.jobnamesuffix }}-*/**/*.cobertura.xml'
- path: '$(Agent.TempDirectory)/coverage'
- - task: PowerShell@2
- name: reportProbe
- displayName: 'Detect code coverage'
- inputs:
- pwsh: true
- targetType: inline
- script: |
- $source = '$(Agent.TempDirectory)/coverage'
- $artifacts = @(Get-ChildItem -Path $source -Directory -ErrorAction SilentlyContinue)
- $reports = @(Get-ChildItem -Path $source -Recurse -File -Filter *.cobertura.xml -ErrorAction SilentlyContinue)
- Write-Host "Downloaded $($reports.Count) Cobertura report(s) from $($artifacts.Count) test job(s)."
- $found = $reports.Count -gt 0
- Write-Host "##vso[task.setvariable variable=HAS_REPORTS;isOutput=true]$($found.ToString().ToLowerInvariant())"
- if ($found) {
- # One artifact per matrix entry. Fewer means a test job passed but
- # published nothing, which silently shrinks the merged report - the
- # project floor only gets stricter, but the changed-lines gate can
- # pass vacuously for files whose assembly went missing. Surface it.
- $expected = 0
- try {
- $expected = @(($env:TEST_MATRIX | ConvertFrom-Json).PSObject.Properties).Count
- }
- catch {
- Write-Host "Could not read the test job matrix: $($_.Exception.Message)"
- }
- if ($expected -gt 0 -and $artifacts.Count -lt $expected) {
- Write-Host "##vso[task.logissue type=warning]Only $($artifacts.Count) of $expected test job(s) published coverage; the merged report is incomplete."
- }
- exit 0
- }
- # Expected on the .NET Framework legs: coverlet.collector 10.x has no
- # net4x build asset, so those jobs never emit a report and this gate
- # degrades to the test-result check. Where the thresholds are enforced
- # a missing report is a hard failure instead - it would otherwise let
- # a silently broken collector pass the gate.
- if ('${{ parameters.enforceCoverage }}' -eq 'true') {
- Write-Host "##vso[task.logissue type=error]No coverage was published by the test jobs; the gate has nothing to evaluate."
- exit 1
- }
- Write-Host "##vso[task.logissue type=warning]No coverage was published by the test jobs; only the test results are checked for this leg."
- exit 0
- env:
- TEST_MATRIX: $(testMatrix)
- - task: DotNetCoreCLI@2
- displayName: Install ReportGenerator tool
- condition: and(succeeded(), eq(variables['reportProbe.HAS_REPORTS'], 'true'))
- inputs:
- command: custom
- custom: tool
- arguments: install --tool-path tools dotnet-reportgenerator-globaltool
- - task: PowerShell@2
- displayName: 'Merge Code Coverage Report'
- condition: and(succeeded(), eq(variables['reportProbe.HAS_REPORTS'], 'true'))
- inputs:
- pwsh: true
- targetType: inline
- workingDirectory: '$(Build.SourcesDirectory)'
- script: |
- $ErrorActionPreference = 'Stop'
- $source = '$(Agent.TempDirectory)/coverage'
- & '$(Build.SourcesDirectory)/tools/reportgenerator' `
- "-reports:$source/**/*.cobertura.xml" `
- '-targetdir:$(Build.SourcesDirectory)/CodeCoverage' `
- '-reporttypes:Cobertura;HtmlSummary' `
- '-title:UA .Net Standard Test Coverage' `
- '-assemblyfilters:-*.Tests'
- if ($LASTEXITCODE -ne 0) {
- Write-Host "##vso[task.logissue type=error]ReportGenerator failed with exit code $LASTEXITCODE."
- exit $LASTEXITCODE
- }
- $summary = '$(Build.SourcesDirectory)/CodeCoverage/summary.htm'
- if (Test-Path $summary) {
- Move-Item -Force $summary '$(Build.SourcesDirectory)/CodeCoverage/index.htm'
- }
- - task: PublishPipelineArtifact@1
- displayName: 'Publish merged coverage report'
- condition: and(succeeded(), eq(variables['reportProbe.HAS_REPORTS'], 'true'))
- continueOnError: true
- inputs:
- targetPath: '$(Build.SourcesDirectory)/CodeCoverage'
- artifact: 'coverage-report-${{ parameters.jobnamesuffix }}'
- # Azure DevOps keeps a single code-coverage summary per build and Codacy
- # accepts a single report per commit, so only the invocation flagged as the
- # publishing one uploads - the remaining legs keep their report as the
- # pipeline artifact published above.
- - ${{ if parameters.publishCoverage }}:
- - task: PublishCodeCoverageResults@2
- displayName: 'Publish code coverage'
- condition: and(succeeded(), eq(variables['reportProbe.HAS_REPORTS'], 'true'))
- inputs:
- summaryFileLocation: '$(Build.SourcesDirectory)/CodeCoverage/Cobertura.xml'
- continueOnError: true
- - script: |
- bash <(curl -Ls https://coverage.codacy.com/get.sh) report -r $REPORT_LOCATION --commit-uuid $COMMIT_UUID
-
- condition: and(succeeded(), eq(variables['reportProbe.HAS_REPORTS'], 'true'), ne(variables['CODACY_PROJECT_TOKEN'], ''))
- env:
- CODACY_PROJECT_TOKEN: $(CODACY_PROJECT_TOKEN)
- REPORT_LOCATION: '$(Build.SourcesDirectory)/CodeCoverage/Cobertura.xml'
- COMMIT_UUID: '$(Build.SourceVersion)'
- continueOnError: true
- displayName: 'Upload to Codacy'
- # Coverage gate. Replaces the codecov/project and codecov/patch status checks:
- # a blocking absolute floor over the whole report, a blocking changed-lines
- # check on pull requests, and an advisory comparison against the recorded
- # baseline. Runs last so the report is published even when the gate fails.
- - task: PowerShell@2
- displayName: 'Coverage gate'
- condition: and(succeeded(), eq(variables['reportProbe.HAS_REPORTS'], 'true'))
- # Advisory unless the caller opted in: only the leg whose thresholds are
- # calibrated blocks the build; the others still fail on test failures.
- ${{ if not(parameters.enforceCoverage) }}:
- continueOnError: true
- inputs:
- pwsh: true
- targetType: inline
- workingDirectory: '$(Build.SourcesDirectory)'
- script: |
- # Empty on anything that is not a pull request validation build, in which
- # case check-coverage.ps1 enforces the project floor only.
- $baseRef = $env:SYSTEM_PULLREQUEST_TARGETBRANCH
- if ([string]::IsNullOrWhiteSpace($baseRef)) {
- Write-Host 'Not a pull request build; the changed-lines gate is skipped.'
- }
- else {
- Write-Host "Pull request targets '$baseRef'."
- }
-
- & ./.azurepipelines/check-coverage.ps1 `
- -CoberturaPath '$(Build.SourcesDirectory)/CodeCoverage/Cobertura.xml' `
- -ThresholdsPath '$(Build.SourcesDirectory)/coverage-thresholds.json' `
- -RepoRoot '$(Build.SourcesDirectory)' `
- -BaseRef $baseRef
- exit $LASTEXITCODE
\ No newline at end of file
diff --git a/.github/workflows/buildandtest.yml b/.github/workflows/buildandtest.yml
index 3bf6d6f975..e7ebb5f244 100644
--- a/.github/workflows/buildandtest.yml
+++ b/.github/workflows/buildandtest.yml
@@ -31,11 +31,13 @@ permissions:
# switch - the other half is the ciBuildBackend parameter in
# azure-pipelines.yml. BOTH MUST BE FLIPPED TOGETHER.
#
-# ado - (default) Azure Pipelines owns the work on master/main: the jobs
-# below stand down there and run only on the branches Azure
-# Pipelines does not cover (master378, develop/*).
-# actions - restores the previous behaviour: every job below runs here on
-# GitHub-hosted runners for every triggering branch.
+# ado - Azure Pipelines owns the work on master/main: the jobs below stand
+# down there and run only on the branches Azure Pipelines does not
+# cover (master378, develop/*).
+# actions - (default) the work is split with Azure Pipelines: every job below
+# runs here on GitHub-hosted runners for every triggering branch,
+# while Azure Pipelines keeps the fast PR test legs and the coverage
+# gate.
#
# The macOS legs are deliberately exempt from this switch: Managed DevOps Pools
# provide no macOS image, so macOS coverage has to stay on GitHub-hosted
@@ -45,7 +47,14 @@ permissions:
# conditions, so the switch is resolved once in the `discover` job below and
# re-exported as job outputs.
env:
- CI_BUILD_BACKEND: ado
+ CI_BUILD_BACKEND: actions
+ # 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'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -165,6 +174,14 @@ jobs:
needs: discover
name: test-${{matrix.os}}-${{matrix.csproj}}
runs-on: ${{ matrix.os }}
+ # Bound the job well below the 360-minute default. These entries normally
+ # take 3-15 minutes, so anything approaching an hour is a hang, and letting
+ # it run for six hours blocks the pull-request gate with no diagnostic
+ # information (observed on run 30988867402, where the Core and Client
+ # entries sat for ~6 hours). --blame-hang-timeout below turns the common
+ # case into a named test failure long before this fires; this is the
+ # backstop for a hang outside the test host itself.
+ timeout-minutes: 60
# test_os is empty when Azure Pipelines owns the Linux matrix and macOS is
# not included for this event; skip rather than fan out to nothing.
if: ${{ needs.discover.outputs.test_os != '[]' }}
@@ -233,6 +250,17 @@ jobs:
'--collect:XPlat Code Coverage',
'--settings',
'./tests/coverlet.runsettings.xml',
+ # Kill (and name) any single test that exceeds 10 minutes, so a silent
+ # hang surfaces as "Test exceeded the configured timeout"
+ # instead of burning the whole job timeout with nothing to go on. The
+ # LongRunning and Stress tiers do not run here, so 10 minutes is well
+ # above any legitimate test. This mirrors what the Azure Pipelines
+ # test template already does; the mini dump captures every thread's
+ # stack, which is what a teardown/shutdown hang needs to diagnose.
+ '--blame-hang-timeout',
+ '10m',
+ '--blame-hang-dump-type',
+ 'mini',
'--results-directory',
'${{ env.TESTRESULTS }}')
@@ -251,6 +279,188 @@ jobs:
# Use always() to always run this step to publish test results when there are test failures
if: ${{ always() }}
+ # Advisory coverage check. Mirrors the Azure Pipelines 'Code coverage' stage:
+ # it merges the Cobertura fragments the matrix above already collects, runs the
+ # same check-coverage.ps1 against the same coverage-thresholds.json, and
+ # surfaces the numbers in the run summary and on the pull request.
+ #
+ # It reports a clean failure when the thresholds are missed so the miss is
+ # visible, but it is deliberately NOT in `build-and-test summary`'s `needs`,
+ # so it can never block the required check. Do not add it to the branch
+ # ruleset.
+ 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 test_os condition build-and-test itself uses,
+ # rather than on gh_owns_build - a push or scheduled run under the 'ado'
+ # backend still executes the macOS legs here, and their coverage would
+ # otherwise be uploaded and then discarded.
+ if: ${{ always() && needs.discover.outputs.test_os != '[]' && 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.x'
+
+ - 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 . `
+ -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 = '';
+ 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 }}
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index d4c5d8bd4f..d7a16abc8b 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -16,25 +16,54 @@ parameters:
# switch - the other half is CI_BUILD_BACKEND in
# .github/workflows/buildandtest.yml. BOTH MUST BE FLIPPED TOGETHER.
#
-# ado - (default) Azure Pipelines owns the work: the Linux all-TFM build
-# leg, the Linux test leg, Native AoT and the coverage gate all run
-# on pull requests, and the equivalent GitHub Actions jobs stand
-# down.
-# actions - restores the previous split: GitHub Actions runs the all-TFM
-# builds, the ubuntu test matrix and AoT, and the stages below fall
-# back to their Schedule/Manual-only cadence.
+# ado - Azure Pipelines owns the work: the Linux all-TFM build leg, the
+# Linux test leg and Native AoT all run on pull requests, and the
+# equivalent GitHub Actions jobs stand down.
+# actions - (default) the work is split across both CI systems: GitHub Actions
+# runs the all-TFM builds, the ubuntu test matrix and AoT, and the
+# stages below fall back to their Schedule/Manual-only cadence.
+#
+# Either way the per-(framework, configuration) coverage gate stays here, in the
+# always-on 'Fast PR test' stage, because it is the required status check.
#
# Note on scope: Azure Pipelines evaluates the YAML from the PR's merge ref, so
# a PR targeting master378 or a release branch runs *that* branch's copy of this
# file. In practice every PR that evaluates this file targets master, which is
# why the conditions below only need the parameter and not a target-branch test.
- name: ciBuildBackend
- displayName: 'CI build backend: ado runs the all-TFM build, cross-platform tests, AoT and the coverage gate here; actions hands them back to GitHub Actions'
+ displayName: 'CI build backend: actions splits the all-TFM build, cross-platform tests and AoT with GitHub Actions; ado runs all of it here'
type: string
- default: ado
+ default: actions
values:
- ado
- actions
+# Codecov is reporting only - the enforced coverage rules live in
+# coverage-thresholds.json and are applied by check-coverage.ps1 in the
+# 'Code coverage' stage. Turn this off to stop uploading entirely; the upload
+# is skipped anyway whenever the CODECOV_TOKEN secret variable is unset, which
+# is the case for pull requests from forks.
+- name: enableCodecov
+ displayName: 'Upload the merged coverage report to codecov.io (reporting only, never gates)'
+ type: boolean
+ default: true
+# Every stage that runs tests, consumed by the two terminal stages at the bottom
+# of this file. It is a parameter purely so the list is written once: Azure
+# Pipelines supports no YAML anchors, and a second hand-maintained copy would
+# drift. There is no reason to override it at queue time.
+- name: testStages
+ displayName: 'Test stages rolled up by the Tests passed and Code coverage stages (do not change)'
+ type: object
+ default:
+ - build
+ - testfastpr
+ - testaot
+ - testdebug
+ - testnet90
+ - testnet80
+ - testnet472
+ - testnetstandard20
+ - testnetstandard21
+ - testnightly
trigger:
batch: 'true'
@@ -81,9 +110,9 @@ variables:
FullBuild: ${{ ne(variables['Build.Reason'], 'PullRequest') }}
ScheduledBuild: ${{ in(variables['Build.Reason'], 'Schedule', 'Manual') }}
# 'True' when Azure Pipelines owns the build/test matrix that GitHub Actions
- # used to run (see the ciBuildBackend parameter). Native AoT, previously
- # Schedule/Manual-only, additionally runs on pull requests while this is True,
- # because it is then the only place that work happens.
+ # otherwise runs (see the ciBuildBackend parameter). Native AoT, normally
+ # Schedule/Manual-only here, additionally runs on pull requests while this is
+ # True, because it is then the only place that work happens.
AdoOwnsMatrix: ${{ eq(parameters.ciBuildBackend, 'ado') }}
# macOS jobs always run on Microsoft-hosted agents because Managed DevOps Pools
# does not provide macOS images.
@@ -139,11 +168,11 @@ stages:
${{ if ne(variables.FullBuild, 'False') }}:
tfms: 'net472,net48,netstandard2.0,netstandard2.1,net8.0,net9.0,net10.0'
${{ elseif eq(parameters.ciBuildBackend, 'ado') }}:
- # ADO now owns the all-TFM PR build that GitHub Actions used to run, so
- # a PR has to cover all seven TFMs. Windows takes the .NET Framework
+ # ADO owns the all-TFM PR build that GitHub Actions normally runs, so a
+ # PR has to cover all seven TFMs. Windows takes the .NET Framework
# targets and the Linux leg below takes the modern ones - the same split
- # the build-all-tfm-windows / build-all-tfm-linux jobs used - which
- # keeps full coverage without fanning every TFM across both pools.
+ # the build-all-tfm-windows / build-all-tfm-linux jobs use - which keeps
+ # full coverage without fanning every TFM across both pools.
tfms: 'net472,net48,netstandard2.0'
${{ else }}:
# GitHub Actions owns the all-TFM build; this PR gate only needs the two
@@ -173,16 +202,14 @@ stages:
jobnamesuffix: net48
poolName: ${{ variables.mgdPoolName }}
windowsImage: ${{ variables.mgdWindowsImage }}
- coveragePoolName: ${{ variables.hostPoolName }}
- coverageImage: ${{ variables.hostLinuxImage }}
- template: .azurepipelines/test.yml
parameters:
configuration: Release
framework: net10.0
- # When ADO owns the matrix this leg also covers Linux, replacing the
- # GitHub Actions build-and-test job that ran every test project on
- # ubuntu. With ciBuildBackend=actions it stays Windows-only, exactly as
- # before, because GitHub Actions is still providing the Linux coverage.
+ # With the default ciBuildBackend=actions this leg stays Windows-only
+ # because the GitHub Actions build-and-test job provides the Linux
+ # coverage. When ADO owns the matrix it also covers Linux, replacing
+ # that job.
${{ if eq(parameters.ciBuildBackend, 'ado') }}:
agents: '@{ windows = "${{ variables.mgdWindowsImage }}"; linux = "${{ variables.mgdLinuxImage }}" }'
${{ else }}:
@@ -191,14 +218,6 @@ stages:
customtestarget: net10.0
poolName: ${{ variables.mgdPoolName }}
windowsImage: ${{ variables.mgdWindowsImage }}
- coveragePoolName: ${{ variables.hostPoolName }}
- coverageImage: ${{ variables.hostLinuxImage }}
- # The reference leg: net10.0 Release is the configuration the thresholds in
- # coverage-thresholds.json are calibrated against, so this is the only one
- # whose coverage gate blocks, and the only one that publishes the merged
- # report to the build summary and to Codacy.
- enforceCoverage: true
- publishCoverage: true
- template: .azurepipelines/test.yml
parameters:
configuration: Release
@@ -209,8 +228,6 @@ stages:
hosted: true
poolName: ${{ variables.mgdPoolName }}
windowsImage: ${{ variables.mgdWindowsImage }}
- coveragePoolName: ${{ variables.hostPoolName }}
- coverageImage: ${{ variables.hostLinuxImage }}
# macOS runs only on Schedule/Manual (nightly + on-demand), not on
# PR or master push, to keep the PR gate fast and Windows-focused.
# Passed bare: test.yml wraps it in succeeded() for the test jobs and in
@@ -220,8 +237,8 @@ stages:
- stage: testaot
dependsOn: []
displayName: 'Test Native AoT'
- # Native AoT is Schedule/Manual-only when GitHub Actions still runs its own
- # AoT job. Once ADO owns the matrix this is the only place AoT runs, so it
+ # Native AoT is Schedule/Manual-only while GitHub Actions runs its own ubuntu
+ # AoT job. When ADO owns the matrix this is the only place AoT runs, so it
# has to gate pull requests too.
condition: and(succeeded(), or(ne(variables.ScheduledBuild, 'False'), ne(variables.AdoOwnsMatrix, 'False')))
jobs:
@@ -244,8 +261,6 @@ stages:
jobnamesuffix: net100debug
poolName: ${{ variables.hostPoolName }}
windowsImage: ${{ variables.hostWindowsImage }}
- coveragePoolName: ${{ variables.hostPoolName }}
- coverageImage: ${{ variables.hostLinuxImage }}
agents: '@{ windows = "${{ variables.hostWindowsImage }}"; linux = "${{ variables.hostLinuxImage }}" }'
- template: .azurepipelines/test.yml
parameters:
@@ -256,8 +271,6 @@ stages:
hosted: true
poolName: ${{ variables.hostPoolName }}
windowsImage: ${{ variables.hostWindowsImage }}
- coveragePoolName: ${{ variables.hostPoolName }}
- coverageImage: ${{ variables.hostLinuxImage }}
- stage: testnet90
dependsOn: [build]
displayName: 'Test .NET 9.0'
@@ -270,8 +283,6 @@ stages:
jobnamesuffix: net90
poolName: ${{ variables.mgdPoolName }}
windowsImage: ${{ variables.mgdWindowsImage }}
- coveragePoolName: ${{ variables.hostPoolName }}
- coverageImage: ${{ variables.hostLinuxImage }}
agents: '@{ windows = "${{ variables.mgdWindowsImage }}"; linux = "${{ variables.mgdLinuxImage }}" }'
- template: .azurepipelines/test.yml
parameters:
@@ -282,8 +293,6 @@ stages:
hosted: true
poolName: ${{ variables.mgdPoolName }}
windowsImage: ${{ variables.mgdWindowsImage }}
- coveragePoolName: ${{ variables.hostPoolName }}
- coverageImage: ${{ variables.hostLinuxImage }}
- stage: testnet80
dependsOn: [build]
displayName: 'Test .NET 8.0'
@@ -296,8 +305,6 @@ stages:
jobnamesuffix: net80
poolName: ${{ variables.hostPoolName }}
windowsImage: ${{ variables.hostWindowsImage }}
- coveragePoolName: ${{ variables.hostPoolName }}
- coverageImage: ${{ variables.hostLinuxImage }}
agents: '@{ windows = "${{ variables.hostWindowsImage }}"; linux = "${{ variables.hostLinuxImage }}" }'
- template: .azurepipelines/test.yml
parameters:
@@ -308,8 +315,6 @@ stages:
hosted: true
poolName: ${{ variables.hostPoolName }}
windowsImage: ${{ variables.hostWindowsImage }}
- coveragePoolName: ${{ variables.hostPoolName }}
- coverageImage: ${{ variables.hostLinuxImage }}
- stage: testnet472
dependsOn: [build]
displayName: 'Test .NET 4.7.2'
@@ -324,8 +329,6 @@ stages:
customtestarget: net472
poolName: ${{ variables.hostPoolName }}
windowsImage: ${{ variables.hostWindowsImage }}
- coveragePoolName: ${{ variables.hostPoolName }}
- coverageImage: ${{ variables.hostLinuxImage }}
- stage: testnetstandard20
dependsOn: [build]
displayName: 'Test .NETStandard 2.0'
@@ -340,8 +343,6 @@ stages:
customtestarget: netstandard2.0
poolName: ${{ variables.mgdPoolName }}
windowsImage: ${{ variables.mgdWindowsImage }}
- coveragePoolName: ${{ variables.hostPoolName }}
- coverageImage: ${{ variables.hostLinuxImage }}
- stage: testnetstandard21
dependsOn: [build]
displayName: 'Test .NETStandard 2.1'
@@ -355,8 +356,6 @@ stages:
customtestarget: netstandard2.1
poolName: ${{ variables.mgdPoolName }}
windowsImage: ${{ variables.mgdWindowsImage }}
- coveragePoolName: ${{ variables.hostPoolName }}
- coverageImage: ${{ variables.hostLinuxImage }}
agents: '@{ windows = "${{ variables.mgdWindowsImage }}"; linux = "${{ variables.mgdLinuxImage }}" }'
- template: .azurepipelines/test.yml
parameters:
@@ -368,8 +367,6 @@ stages:
hosted: true
poolName: ${{ variables.mgdPoolName }}
windowsImage: ${{ variables.mgdWindowsImage }}
- coveragePoolName: ${{ variables.hostPoolName }}
- coverageImage: ${{ variables.hostLinuxImage }}
- stage: testnightly
dependsOn: [build]
displayName: 'Test long-running tiers'
@@ -390,8 +387,9 @@ stages:
customtestarget: net10.0
testfilter: ''
hangtimeout: '30m'
- # A tier, not a (framework, configuration) leg: its numbers cannot be
- # merged into the pull-request gate, so skip the coverage job here.
+ # A tier, not a mainline leg: it re-runs a subset of the same projects
+ # with the filter lifted, so folding its numbers into the merged report
+ # would double-count. Skip coverage collection here.
coverage: false
agents: '@{ linux = "${{ variables.hostLinuxImage }}" }'
poolName: ${{ variables.hostPoolName }}
@@ -411,3 +409,69 @@ stages:
agents: '@{ windows = "${{ variables.hostWindowsImage }}"; linux = "${{ variables.hostLinuxImage }}" }'
poolName: ${{ variables.hostPoolName }}
windowsImage: ${{ variables.hostWindowsImage }}
+
+
+# Required check. Rolls every test stage up into a single status so the GitHub
+# branch ruleset does not have to name matrix-generated jobs, whose names change
+# whenever a test project or an agent is added.
+#
+# The verdict lives entirely in the stage `condition`, which is the one place
+# Azure Pipelines reliably exposes stage results. Two earlier attempts failed:
+# a stage-scoped `$[ ]` variable is evaluated when the JOB is prepared, where
+# `dependencies` means sibling jobs, so `convertToJson(dependencies)` resolved
+# to '{}' (build 16520); and `convertToJson(stageDependencies)` at job scope
+# blew the expression memory limit, because this pipeline's ~115 jobs carry
+# large output variables such as the serialised test matrix (build 16583).
+# Reading `dependencies..result` here costs nothing and is the documented
+# usage.
+#
+# 'Skipped' is accepted because the Schedule/Manual-only stages report exactly
+# that on a pull request. When a stage genuinely fails the condition is false,
+# this stage is skipped, and Azure Pipelines then posts NO check for it - which
+# leaves the required check permanently unfulfilled and blocks the merge. That
+# is deliberately fail-closed: the failing job itself is already red, and a
+# required check that never reports can never be mistaken for a passing one.
+- stage: gate
+ displayName: 'Tests passed'
+ dependsOn: ${{ parameters.testStages }}
+ condition: |
+ and(
+ not(canceled()),
+ in(dependencies.build.result, 'Succeeded', 'SucceededWithIssues', 'Skipped'),
+ in(dependencies.testfastpr.result, 'Succeeded', 'SucceededWithIssues', 'Skipped'),
+ in(dependencies.testaot.result, 'Succeeded', 'SucceededWithIssues', 'Skipped'),
+ in(dependencies.testdebug.result, 'Succeeded', 'SucceededWithIssues', 'Skipped'),
+ in(dependencies.testnet90.result, 'Succeeded', 'SucceededWithIssues', 'Skipped'),
+ in(dependencies.testnet80.result, 'Succeeded', 'SucceededWithIssues', 'Skipped'),
+ in(dependencies.testnet472.result, 'Succeeded', 'SucceededWithIssues', 'Skipped'),
+ in(dependencies.testnetstandard20.result, 'Succeeded', 'SucceededWithIssues', 'Skipped'),
+ in(dependencies.testnetstandard21.result, 'Succeeded', 'SucceededWithIssues', 'Skipped'),
+ in(dependencies.testnightly.result, 'Succeeded', 'SucceededWithIssues', 'Skipped')
+ )
+ jobs:
+ - job: gate
+ displayName: Verify stage results
+ timeoutInMinutes: 10
+ pool:
+ ${{ if ne(variables.hostPoolName, '') }}:
+ name: ${{ variables.hostPoolName }}
+ demands:
+ - ImageOverride -equals ${{ variables.hostLinuxImage }}
+ ${{ else }}:
+ vmImage: ${{ variables.hostLinuxImage }}
+ steps:
+ # Nothing is built or read from the tree; reaching this job IS the verdict.
+ - checkout: none
+ - pwsh: |
+ Write-Host 'Every test stage succeeded or was intentionally skipped.'
+ displayName: 'Tests passed'
+# Advisory check. Merges every Cobertura fragment the run produced and
+# evaluates coverage-thresholds.json once. It reports a clean failure when the
+# thresholds are missed so the miss is visible, but it must NOT be added to the
+# GitHub branch ruleset - 'Tests passed' above is the required check.
+- template: .azurepipelines/coverage.yml
+ parameters:
+ poolName: ${{ variables.hostPoolName }}
+ poolImage: ${{ variables.hostLinuxImage }}
+ stages: ${{ parameters.testStages }}
+ enableCodecov: ${{ parameters.enableCodecov }}
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 0000000000..b150697db1
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,60 @@
+# Codecov configuration
+#
+# Validate with:
+# curl --data-binary @codecov.yml https://codecov.io/validate
+#
+# Codecov is REPORTING, not a gate. The enforced coverage rules live in
+# coverage-thresholds.json and are applied by .azurepipelines/check-coverage.ps1
+# inside the pipeline, where they can react to how much a patch actually
+# changed. Codecov is kept for what it is genuinely better at: the pull-request
+# comment, the file-by-file diff view and the coverage trend over time.
+#
+# Both status checks are therefore `informational: true`. They post a status so
+# the number is visible, but they never fail and must never be added to the
+# branch ruleset - two gates with two sets of thresholds would sooner or later
+# disagree about the same pull request, and the one that is easier to silence
+# would win. Flip `informational` to false only if you also retire the
+# in-pipeline patch gate.
+#
+# The upload itself is optional on both CI systems and is skipped when the
+# CODECOV_TOKEN secret is absent (as it is on fork pull requests). See
+# `enableCodecov` in azure-pipelines.yml and `ENABLE_CODECOV` in
+# .github/workflows/buildandtest.yml.
+coverage:
+ precision: 2
+ round: down
+ status:
+ project:
+ default:
+ target: auto
+ threshold: 1%
+ informational: true
+ only_pulls: true
+ patch:
+ default:
+ target: 80%
+ threshold: 5%
+ informational: true
+ only_pulls: true
+
+# Kept in step with the "ignore" list in coverage-thresholds.json so Codecov and
+# the in-pipeline gate measure the same code. Change both together.
+ignore:
+ - "tests/**"
+ - "samples/**"
+ - "**/obj/**"
+ - "**/bin/**"
+ - "**/*.g.cs"
+
+# Each CI system uploads the report it merged, under its own flag. The two
+# matrices are deliberately different - Azure runs the Windows fast-PR legs,
+# Actions runs the full ubuntu matrix - so carrying the flags keeps the two
+# uploads from being mistaken for a coverage swing.
+flag_management:
+ default_rules:
+ carryforward: true
+
+comment:
+ layout: "reach, diff, flags, files"
+ behavior: default
+ require_changes: false
diff --git a/coverage-thresholds.json b/coverage-thresholds.json
index 6a8ac13641..3213e4ed32 100644
--- a/coverage-thresholds.json
+++ b/coverage-thresholds.json
@@ -1,30 +1,49 @@
{
"$comment": [
- "Coverage gates for the UA .NET Standard stack. These replace the codecov/project",
- "and codecov/patch status checks; see .azurepipelines/check-coverage.ps1 for the",
- "implementation and docs/DeveloperGuide.md for the contributor-facing description.",
+ "Coverage gates for the UA .NET Standard stack. These are the ENFORCED rules;",
+ "see .azurepipelines/check-coverage.ps1 for the implementation and",
+ "docs/DeveloperGuide.md for the contributor-facing description.",
"Rates are percentages (0-100).",
"",
- "'project' is an absolute floor over the whole merged coverage report and BLOCKS.",
+ "codecov.io is still uploaded to, but only for reporting - its own",
+ "codecov/project and codecov/patch statuses are informational (see",
+ "codecov.yml) so that there is exactly one gate rather than two sets of",
+ "thresholds that can disagree about the same pull request.",
+ "",
+ "IMPORTANT: the coverage check is ADVISORY. Both the Azure 'Code coverage' stage and",
+ "the GitHub Actions 'code coverage' job report a clean failure when these thresholds",
+ "are missed, so the miss is visible on the pull request, but neither belongs in the",
+ "branch ruleset. The required checks are 'Tests passed' and 'build-and-test summary',",
+ "which only assert that the tests themselves passed.",
+ "",
+ "'project' is an absolute floor over the whole merged coverage report.",
"Ratchet minimumLineRate/minimumBranchRate upward (together with baselineLineRate)",
- "as coverage improves - never downward to make a red build go green.",
+ "as coverage improves - never downward to make a red check go green.",
"",
"The initial values are seeded from the last codecov project reading (73.61%) with",
"headroom, because the in-pipeline number had not been observed yet when the gate",
- "was introduced. Confirm them against the first full Code Coverage run on master",
- "and tighten them then - the gate prints the exact figure it computed.",
+ "was introduced.",
"",
- "The report the gate evaluates is assembled from the fanned-out test matrix, which",
- "excludes Opc.Ua.Stress.Tests / Opc.Ua.Subscriptions.Durable.Tests and the",
- "LongRunning and Stress categories, so it reads slightly lower than the pre-2026",
- "single-job coverage run that these figures were seeded from. Re-calibrate against",
- "the net10.0 Release 'Coverage' job, which is the only leg that blocks.",
+ "The report is assembled from whichever legs of the run published Cobertura fragments,",
+ "so the number moves with the matrix: the GitHub Actions job merges every test project",
+ "on ubuntu and is the most representative, the Azure stage merges only the Windows",
+ "fast-PR legs, and scheduled runs read higher because the Debug / .NET 8 / .NET 9 /",
+ "netstandard stages contribute too. Re-calibrate against the GitHub Actions figure.",
"",
"'patch' mirrors the old codecov/patch semantics: changed lines must reach",
- "target percent, tolerating threshold percentage points. It BLOCKS.",
+ "target percent, tolerating threshold percentage points. The requirement is",
+ "graduated by how much actually changed, because a percentage over a handful of",
+ "lines is noise: one uncovered line in a two-line fix reads as 50% and would fail",
+ "a flat floor, which teaches authors to ignore the check rather than fix it.",
+ "'bands' is consulted in order and the first band whose maxChangedLines covers the",
+ "patch wins; a band with enforced=false reports a warning instead of failing.",
+ "Anything larger than the last band falls through to target-threshold and is",
+ "always enforced, because at that size the percentage is meaningful and a big",
+ "untested change is exactly what the check exists to catch.",
"",
- "'ignore' carries over the exclusion list from the retired codecov.yml and is",
- "applied to BOTH the project floor and the changed-lines gate."
+ "'ignore' mirrors the exclusion list in codecov.yml so both report on the same",
+ "code - change the two together - and is applied to BOTH the project floor and",
+ "the changed-lines check."
],
"project": {
"minimumLineRate": 70.0,
@@ -34,7 +53,11 @@
},
"patch": {
"target": 80.0,
- "threshold": 5.0
+ "threshold": 5.0,
+ "bands": [
+ { "maxChangedLines": 10, "target": 50.0, "enforced": false },
+ { "maxChangedLines": 100, "target": 60.0, "enforced": false }
+ ]
},
"ignore": [
"tests/**",
diff --git a/docs/DeveloperGuide.md b/docs/DeveloperGuide.md
index 45c75c1c64..b599b93322 100644
--- a/docs/DeveloperGuide.md
+++ b/docs/DeveloperGuide.md
@@ -259,8 +259,8 @@ From **2.0** onward, package versions are produced by [Nerdbank.GitVersioning](h
Two CI systems run against this repository:
-- **Azure Pipelines** ([`azure-pipelines.yml`](../azure-pipelines.yml) plus the templates in [`.azurepipelines/`](../.azurepipelines)) — the all-target-framework solution build, the cross-platform test matrices, Native AoT and the coverage gate.
-- **GitHub Actions** ([`.github/workflows/`](../.github/workflows)) — CodeQL, container images, the opt-in stress and stability suites, and the macOS legs of the build/test matrix.
+- **Azure Pipelines** ([`azure-pipelines.yml`](../azure-pipelines.yml) plus the templates in [`.azurepipelines/`](../.azurepipelines)) — the fast pull-request test legs, the per-framework test matrices and the coverage gate, on the `netstandard` Managed DevOps Pool and Microsoft-hosted agents.
+- **GitHub Actions** ([`.github/workflows/`](../.github/workflows)) — the all-target-framework solution builds, the ubuntu test matrix, Native AoT, CodeQL, container images, the opt-in stress and stability suites, and the macOS legs of the build/test matrix.
### Which system runs what
@@ -268,15 +268,16 @@ A single conceptual switch decides who owns the all-TFM build, the cross-platfor
| File | Setting | Default |
| --- | --- | --- |
-| [`azure-pipelines.yml`](../azure-pipelines.yml) | `parameters.ciBuildBackend` | `ado` |
-| [`.github/workflows/buildandtest.yml`](../.github/workflows/buildandtest.yml) | `env.CI_BUILD_BACKEND` | `ado` |
+| [`azure-pipelines.yml`](../azure-pipelines.yml) | `parameters.ciBuildBackend` | `actions` |
+| [`.github/workflows/buildandtest.yml`](../.github/workflows/buildandtest.yml) | `env.CI_BUILD_BACKEND` | `actions` |
-With the default `ado`, Azure Pipelines runs that work on the `netstandard` Managed DevOps Pool and the equivalent GitHub Actions jobs stand down on `master`/`main`. Setting both to `actions` restores the previous split, where GitHub Actions ran the ubuntu test matrix, Native AoT and the all-TFM builds.
+With the default `actions` the load is split across both systems: GitHub Actions runs the all-TFM builds, the ubuntu test matrix and Native AoT, while Azure Pipelines runs the fast pull-request test legs on the managed pool and hosts the coverage gate. Setting both to `ado` moves that work onto the Managed DevOps Pool as well, and the equivalent GitHub Actions jobs stand down on `master`/`main`.
-Two things are deliberately *not* covered by the switch:
+Three things are deliberately *not* covered by the switch:
- **macOS** always runs on GitHub-hosted runners, because Managed DevOps Pools provide no macOS image.
- **`master378` and `develop/*`** keep running the GitHub Actions jobs regardless of the setting, since Azure Pipelines only builds `master`/`main` from this file.
+- **The `Tests passed` and `Code coverage` stages** always run in Azure Pipelines regardless of the switch, because they roll up whatever did run (see [Required checks and coverage](#required-checks-and-coverage)).
### Test tiers
@@ -289,7 +290,7 @@ The fast test stages fan every `*.Tests.csproj` out across matrix jobs and filte
| `Opc.Ua.Stress.Tests` | [`.github/workflows/stress-test.yml`](../.github/workflows/stress-test.yml), opt-in |
| `Opc.Ua.Aot.Tests` | `Test Native AoT` stage |
-Because the individual matrix jobs are generated (and are skipped outright when Azure Pipelines owns them, or when a pull request touches no build-relevant files), branch protection should require the aggregate **`build-and-test summary`** check rather than any individual job. That job runs on every pull request — the workflow deliberately carries no `paths:` filter, because a workflow filtered out by `paths` never reports its checks and a required check that never reports blocks the pull request forever. The path allow-list is applied inside the `discover` job instead, and the summary treats an intentionally skipped job as success.
+Because the individual matrix jobs are generated (and are skipped outright when Azure Pipelines owns them, or when a pull request touches no build-relevant files), branch protection requires the aggregate **`build-and-test summary`** check rather than any individual job — see [Required checks and coverage](#required-checks-and-coverage). That job runs on every pull request — the workflow deliberately carries no `paths:` filter, because a workflow filtered out by `paths` never reports its checks and a required check that never reports blocks the pull request forever. The path allow-list is applied inside the `discover` job instead, and the summary treats an intentionally skipped job as success.
### Triggering a pipeline run on a pull request
@@ -305,35 +306,95 @@ To start the run, a repository owner or a collaborator with `Write` permission c
This setting lives in the Azure DevOps portal (pipeline → **More actions** → **Triggers** → **Pull request validation**), not in YAML.
-### Coverage gates
+### Required checks and coverage
-Every test stage ends in a **`Coverage ()`** job. Each test matrix entry collects coverage while it runs and publishes its raw Cobertura fragment as a pipeline artifact; the coverage job then re-assembles those fragments with ReportGenerator and evaluates them. It never re-runs the tests — doing so would serialise a suite that was deliberately fanned out across matrix jobs and blow the stage timeout.
+Two concerns are deliberately kept apart, and both CI systems expose the same pair of checks:
-That job is the single per-(framework, configuration) signal, so it — not an individual matrix entry — is what the GitHub branch ruleset should require. It fails when:
+| Concern | Azure Pipelines | GitHub Actions | In the branch ruleset? |
+| --- | --- | --- | --- |
+| Every test passed | **`Tests passed`** stage | **`build-and-test summary`** job | **Yes — required** |
+| Coverage meets the thresholds | **`Code coverage`** stage | **`code coverage`** job | **No — advisory** |
-- any test job in its stage failed, was cancelled or never ran,
-- no coverage was produced at all,
-- or, on the reference leg, the merged report misses the thresholds.
+Azure Pipelines reports its checks to GitHub as ` ()`, so the two names to look for in the ruleset are `OPCFoundation.UA-.NETStandard (Tests passed Verify stage results)` and `OPCFoundation.UA-.NETStandard (Code coverage Merge and evaluate)`.
-Only the **net10.0 Release** leg (`enforceCoverage: true` in [`azure-pipelines.yml`](../azure-pipelines.yml)) blocks on the thresholds and publishes the merged report to the build summary and Codacy — it is the configuration [`coverage-thresholds.json`](../coverage-thresholds.json) is calibrated against. The other legs report their numbers as a warning and still block on test failures. Every leg publishes its merged report as the `coverage-report-` artifact.
+> **`Tests passed` is fail-closed, not fail-red.** Its verdict lives in the stage `condition`, which is the one place Azure Pipelines reliably exposes stage results. When a test stage fails the condition is false, the stage is skipped, and Azure Pipelines posts **no check at all** for it — so the required check stays unfulfilled and the merge stays blocked. You will see the failing test job in red and `Tests passed` still waiting, rather than two red checks.
-The thresholds are enforced by [`.azurepipelines/check-coverage.ps1`](../.azurepipelines/check-coverage.ps1):
+The coverage check reports a clean failure when the thresholds are missed, so a miss is visible on the pull request, but it never blocks the merge. Do not add it to the ruleset — that would make a coverage dip unmergeable, which is not the intent.
+
+Both required checks are single rollup jobs on purpose. The jobs underneath them are matrix-generated, so their names change whenever a test project or an agent is added, and they are skipped wholesale by the CI backend switch or by the path filter. Requiring a generated job name would therefore break as soon as the matrix changed.
+
+The two rollups reach their verdict differently, and the difference matters:
+
+| | How the verdict is reached | What a failing dependency looks like |
+| --- | --- | --- |
+| `build-and-test summary` (Actions) | Runs on `always()` and inspects `needs.*.result` inside the job, calling `exit 1` itself. | The check reports **failure** — a red X. |
+| `Tests passed` (Azure) | Encoded in the stage `condition`, the one place Azure Pipelines reliably exposes stage results. | The stage is skipped and Azure posts **no check** — the required check stays unfulfilled. |
+
+The Actions job must use `always()` (rather than the implicit "all needs succeeded") precisely because a job that is *skipped* because a dependency failed surfaces to GitHub as `skipped`, and a required check reporting `skipped` is treated as **satisfied** — it would wave a red build straight through. The Azure stage is safe from that trap for a different reason: a skipped Azure *stage* posts nothing at all, so there is no `skipped` conclusion for the ruleset to accept. Verified on build 16613, where `Fast PR test` failed, `Tests passed` was skipped, and no `Tests passed` check-run reached the pull request.
+
+#### How coverage is measured
+
+Every test matrix entry collects coverage while it runs and publishes its raw Cobertura fragment as an artifact. The coverage check then downloads every fragment the run produced, merges them **once** with ReportGenerator, and evaluates the merged report. It never re-runs the tests — doing so serialises a suite that was deliberately fanned out across matrix jobs and blows the stage timeout.
+
+The evaluation is [`.azurepipelines/check-coverage.ps1`](../.azurepipelines/check-coverage.ps1), shared by both CI systems and driven by [`coverage-thresholds.json`](../coverage-thresholds.json):
| Check | Behaviour |
| --- | --- |
-| **Project floor** | *Blocking.* Total line and branch rates must meet the absolute floors in `coverage-thresholds.json`. The `ignore` globs are applied here too, so samples, tests and generated code do not count. |
-| **Patch coverage** | *Blocking on pull requests.* Lines you added or modified must reach `patch.target` percent, tolerating `patch.threshold` percentage points. |
-| **Baseline delta** | *Advisory only.* Reports how total coverage compares with the recorded `baselineLineRate` and never fails the build. |
+| **Project floor** | Total line and branch rates must meet the absolute floors in `coverage-thresholds.json`. The `ignore` globs are applied here too, so samples, tests and generated code do not count. |
+| **Patch coverage** | On pull requests, lines you added or modified must reach a floor that is **graduated by how much changed** — see below. The uncovered changed lines are listed by file. |
+| **Baseline delta** | Reports how total coverage compares with the recorded `baselineLineRate`. Warning only, even within this advisory check. |
+
+Ratchet `minimumLineRate`, `minimumBranchRate` and `baselineLineRate` **upward** as coverage improves; never lower them to turn a red check green.
+
+##### Patch coverage is graduated by patch size
+
+A coverage percentage over a handful of lines carries almost no information. One uncovered line in a two-line fix reads as 50%, and a flat floor would fail it — which teaches authors to ignore the check rather than act on it. So the requirement scales with how much actually changed:
+
+| Coverable changed lines | Floor | Below the floor |
+| --- | --- | --- |
+| 1 – 10 | 50 % | :warning: **warning**, check still passes |
+| 11 – 100 | 60 % | :warning: **warning**, check still passes |
+| more than 100 | `patch.target` − `patch.threshold` (75 %) | :x: **failure** |
+
+Only changes larger than the last band can fail the patch check. At that size the percentage is meaningful, and a large untested change is exactly what the check exists to catch. Below it you still get a warning naming the uncovered lines, so the signal is never silent — it just does not block.
+
+The bands live in `patch.bands` in [`coverage-thresholds.json`](../coverage-thresholds.json). They are consulted in order and the first band whose `maxChangedLines` covers the patch wins; set `enforced: true` on a band to make it blocking. Anything larger than the last band falls through to `patch.target` − `patch.threshold` and is always enforced.
+
+Remember that the coverage check as a whole is advisory and stays out of the branch ruleset — an enforced band produces a red `Code coverage` check, not a blocked merge.
+
+##### Codecov
+
+The merged report is also uploaded to [codecov.io](https://codecov.io), which is where the pull-request comment, the file-by-file diff view and the coverage trend live. **Codecov does not gate.** Both of its status checks are `informational: true` in [`codecov.yml`](../codecov.yml), because two gates with two sets of thresholds would eventually disagree about the same pull request and the easier one to silence would win. The rules that actually decide are the ones above.
+
+Each CI system uploads the report it merged, under its own flag (`azure`, `actions`), since the two matrices deliberately cover different legs.
+
+The upload is optional on both systems and never fails a build:
+
+| | Turn it off with | Also skipped when |
+| --- | --- | --- |
+| Azure Pipelines | the `enableCodecov` pipeline parameter (default `true`) | the `CODECOV_TOKEN` secret variable is unset |
+| GitHub Actions | the `ENABLE_CODECOV` workflow `env` (default `'true'`) | the `CODECOV_TOKEN` secret is unavailable, as on fork pull requests |
+
+Keep the `ignore` list in `codecov.yml` in step with the one in `coverage-thresholds.json`, or the two will report on different code.
+
+#### Where the numbers appear
+
+The script renders a markdown summary that both systems surface, so you never have to open a raw log to see why coverage moved:
+
+- **GitHub Actions** — appended to the run's job summary, and posted as a single sticky pull-request comment that is updated in place on each run. Threshold misses additionally appear as run annotations. On a pull request **from a fork** the token is read-only, so the comment is skipped and only the job summary is written.
+- **Azure Pipelines** — attached to the build summary via `##vso[task.uploadsummary]`, alongside the usual Code Coverage tab and the Codacy upload.
+
+Both also publish the merged HTML report as a `coverage-report` artifact.
-Ratchet `minimumLineRate`, `minimumBranchRate` and `baselineLineRate` **upward** as coverage improves; never lower them to turn a red build green.
+> The two systems report **different numbers**, and that is expected. With the default `actions` backend, GitHub Actions merges every test project on ubuntu, whereas Azure Pipelines merges only the Windows fast-PR legs. The GitHub figure is the more representative one. Scheduled runs read higher still, because the Debug, .NET 8/9 and netstandard stages also contribute fragments.
-To reproduce a gate failure locally, generate the same report with [`tests/codecoverage.cmd`](../tests/codecoverage.cmd) (or [`tests/codecoverage.sh`](../tests/codecoverage.sh)) and run the script against it:
+To reproduce a coverage failure locally, generate the same report with [`tests/codecoverage.cmd`](../tests/codecoverage.cmd) (or [`tests/codecoverage.sh`](../tests/codecoverage.sh)) and run the script against it:
```powershell
-./.azurepipelines/check-coverage.ps1 -CoberturaPath ./CodeCoverage/Cobertura.xml -BaseRef master
+./.azurepipelines/check-coverage.ps1 -CoberturaPath ./CodeCoverage/Cobertura.xml -BaseRef master -SummaryPath ./coverage-summary.md
```
-Omit `-BaseRef` to check only the project floor.
+Omit `-BaseRef` to check only the project floor, and `-SummaryPath` to skip the markdown summary.
## Contributing and pull requests
diff --git a/docs/MigrationGuide.md b/docs/MigrationGuide.md
index 73dc35f321..d2e82de12c 100644
--- a/docs/MigrationGuide.md
+++ b/docs/MigrationGuide.md
@@ -85,6 +85,47 @@ provider through the server-wide historian registry, or override
[Server address-space metadata](NodeManagers.md#server-address-space-metadata) and
[Historical Access](HistoricalAccess.md).
+## Migrating custom ISessionManager implementations to ShutdownAsync
+
+`ISessionManager.Shutdown()` is **gone**, replaced by
+`ShutdownAsync(CancellationToken)`. `SessionManager` previously started its
+session monitor loop with a discarded `Task.Factory.StartNew(...)`, so
+`Shutdown()` only *signalled* the loop and returned: the server could
+finish tearing down while the monitor was still closing expired sessions
+and raising keep-alive events against half-disposed state. There is no
+correct synchronous way to wait for that loop — blocking on it would be
+sync-over-async — so the synchronous overload was removed rather than
+kept as a trap. `ShutdownAsync` cancels the loop and awaits it before
+disposing the sessions, matching `ISubscriptionManager.ShutdownAsync`.
+
+**Callers** await instead of calling:
+
+```csharp
+// before
+server.SessionManager.Shutdown();
+
+// after
+await server.SessionManager.ShutdownAsync(cancellationToken)
+ .ConfigureAwait(false);
+```
+
+**Implementers** of `ISessionManager` (for example a manager registered
+through `services.AddSessionManager()`) replace `Shutdown` with
+`ShutdownAsync`. If your implementation has no background work, return a
+completed task:
+
+```csharp
+public ValueTask ShutdownAsync(CancellationToken cancellationToken = default)
+{
+ CloseAllSessions();
+ return default;
+}
+```
+
+Deriving from `SessionManager` requires no change beyond renaming any
+`Shutdown` override: `ShutdownAsync` is `virtual` and the base
+implementation already awaits the monitor loop.
+
## Migrating from 1.05.377 to 1.05.378
### Asynchronous as default
diff --git a/src/Opc.Ua.Client/Session/ManagedSession.CertificateChanges.cs b/src/Opc.Ua.Client/Session/ManagedSession.CertificateChanges.cs
index fc76cfe126..2709b82c74 100644
--- a/src/Opc.Ua.Client/Session/ManagedSession.CertificateChanges.cs
+++ b/src/Opc.Ua.Client/Session/ManagedSession.CertificateChanges.cs
@@ -170,11 +170,15 @@ private void OnCertificateChange(CertificateChangeEvent evt)
{
case CertificateChangeKind.ApplicationCertificateUpdated:
// Own-certificate rotation always needs reload +
- // reconnect — no validation can save it. Fire-and-
- // forget on the thread pool; the inner session's
- // reconnect lock serialises with any in-flight
- // ReconnectAsync (see HandleApplicationCertificateUpdatedAsync).
- _ = Task.Run(() => HandleApplicationCertificateUpdatedAsync(evt));
+ // reconnect — no validation can save it. Scheduled on the
+ // scope so the reload is awaited when the session shuts
+ // down; the inner session's reconnect lock serialises with
+ // any in-flight ReconnectAsync (see
+ // HandleApplicationCertificateUpdatedAsync).
+ m_backgroundWork.Run(
+ nameof(HandleApplicationCertificateUpdatedAsync),
+ async _ => await HandleApplicationCertificateUpdatedAsync(evt)
+ .ConfigureAwait(false));
break;
case CertificateChangeKind.TrustListUpdated:
case CertificateChangeKind.CrlUpdated:
diff --git a/src/Opc.Ua.Client/Session/ManagedSession.cs b/src/Opc.Ua.Client/Session/ManagedSession.cs
index 7de9272a4b..75caada5ed 100644
--- a/src/Opc.Ua.Client/Session/ManagedSession.cs
+++ b/src/Opc.Ua.Client/Session/ManagedSession.cs
@@ -1850,6 +1850,11 @@ public async ValueTask DisposeAsync()
UnsubscribeCertificateChanges();
await StopRevalidationLoopAsync().ConfigureAwait(false);
+ // After unsubscribing (no new work can arrive) and before the inner
+ // session goes away: a certificate reload already scheduled
+ // reconnects through it.
+ await m_backgroundWork.DisposeAsync().ConfigureAwait(false);
+
// Tear down streaming subscription and model change tracker
// before closing the session so any in-flight publish work
// completes against a still-valid session.
@@ -1948,6 +1953,8 @@ public void Dispose()
// TODO: move ManagedSession to async-only disposal.
private CancellationTokenSource? m_identityRefreshCancellation;
#pragma warning restore CA2213
+ private readonly BackgroundTaskScope m_backgroundWork =
+ new(nameof(ManagedSession), AmbientMessageContext.Telemetry);
private Task? m_identityRefreshTask;
private TaskCompletionSource