diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 5cdba8649b..8c657c2a04 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -306,18 +306,18 @@ stages: condition: and(succeeded(), not(and(in(variables['Build.Reason'], 'IndividualCI', 'BatchedCI'), eq(variables['Build.SourceBranch'], 'refs/heads/main')))) jobs: - # Lightweight job that classifies the PR diff into two scopes (product vs samples) - # and exposes them as output variables. Downstream jobs use them to skip work that + # Lightweight job that classifies the PR diff into product, samples, and XML-documentation-only + # scopes and exposes them as output variables. Downstream jobs use them to skip work that # the diff does not touch (so e.g. a samples-only Dependabot PR does not spin up # the full Windows/Linux/macOS product matrix, and a product-only PR does not run # the samples build). # # Fail-safe behaviour: # - Non-PR triggers (manual queue, scheduled, etc.) leave SYSTEM_PULLREQUEST_TARGETBRANCH - # empty, so we emit "true" for both flags and run everything. + # empty, so we emit "true" for both change flags and "false" for XML-documentation-only. # - If the diff is empty (e.g. force-push to the same SHA), we also run everything. - # - Downstream conditions use `ne(..., 'false')` rather than `eq(..., 'true')` - # so a missing/empty output (e.g. detect step failed) still runs the job. + # - Downstream conditions treat missing product/sample outputs as changed and a missing + # XML-documentation-only output as false, so classification uncertainty runs full validation. - job: DetectChanges displayName: Detect changed paths pool: @@ -340,6 +340,7 @@ stages: echo "Not a PR build (SYSTEM_PULLREQUEST_TARGETBRANCH is empty); running all jobs." hasProduct=true hasSamples=true + xmlDocsOnly=false else echo "PR target branch: ${target}" git fetch --no-tags --depth=200 origin "${target}" 2>/dev/null \ @@ -354,6 +355,7 @@ stages: echo "No files changed in diff; running all jobs." hasProduct=true hasSamples=true + xmlDocsOnly=false else # A file is a "samples-affecting" change if it lives under samples/** or it is # one of the eng/ scripts that drives the samples build (build-samples.ps1). @@ -369,13 +371,32 @@ stages: else hasSamples=false fi + + # Never let a PR classify itself with code from its own branch. Extract the classifier from the + # trusted target branch and fail closed when it is missing, broken, or returns an unexpected value. + classifier="${AGENT_TEMPDIRECTORY}/classify-xml-doc-change.ps1" + xmlDocsOnly=false + if ! git show "origin/${target}:eng/classify-xml-doc-change.ps1" > "${classifier}"; then + echo "##vso[task.logissue type=warning]The trusted target branch does not contain the XML documentation classifier; using full validation." + elif ! pwsh -NoProfile -File "${classifier}" -SelfTest; then + echo "##vso[task.logissue type=warning]The trusted XML documentation classifier failed its self-tests; using full validation." + elif ! xmlDocsOnly="$(pwsh -NoProfile -File "${classifier}" -Base "${base}" -Head HEAD -Repository "${BUILD_SOURCESDIRECTORY}")"; then + echo "##vso[task.logissue type=warning]XML documentation change classification did not run; using full validation." + xmlDocsOnly=false + fi + if [[ "${xmlDocsOnly}" != "true" && "${xmlDocsOnly}" != "false" ]]; then + echo "##vso[task.logissue type=warning]XML documentation change classification returned an unexpected value; using full validation." + xmlDocsOnly=false + fi fi fi echo "hasProductChanges=${hasProduct}" echo "hasSamplesChanges=${hasSamples}" + echo "xmlDocsOnly=${xmlDocsOnly}" echo "##vso[task.setvariable variable=hasProductChanges;isOutput=true]${hasProduct}" echo "##vso[task.setvariable variable=hasSamplesChanges;isOutput=true]${hasSamples}" + echo "##vso[task.setvariable variable=xmlDocsOnly;isOutput=true]${xmlDocsOnly}" name: detect displayName: Classify changed files @@ -395,15 +416,17 @@ stages: jobs: - job: Windows dependsOn: DetectChanges - # Release always runs so every public PR computes and publishes coverage. Debug remains skippable for - # samples-only PRs, preserving the changed-path optimization where it does not affect coverage. - condition: and(succeededOrFailed(), or(eq(variables._BuildConfig, 'Release'), ne(dependencies.DetectChanges.outputs['detect.hasProductChanges'], 'false'))) + # Release normally runs full validation and coverage for every PR, but XML-documentation-only PRs use + # it only for compiler and package validation. Debug remains skippable for samples-only and XML-doc-only PRs. + condition: and(succeededOrFailed(), or(eq(variables._BuildConfig, 'Release'), and(ne(dependencies.DetectChanges.outputs['detect.xmlDocsOnly'], 'true'), ne(dependencies.DetectChanges.outputs['detect.hasProductChanges'], 'false')))) timeoutInMinutes: 90 pool: name: NetCore-Public demands: ImageOverride -equals windows.vs2026preview.scout.amd64.open variables: - template: /eng/pipelines/variables/test-env-vars.yml + - name: _XmlDocsOnly + value: $[ dependencies.DetectChanges.outputs['detect.xmlDocsOnly'] ] strategy: matrix: Release: @@ -750,7 +773,7 @@ stages: inputs: PathtoPublish: '$(Build.SourcesDirectory)/artifacts/TestResults/$(_BuildConfig)' ArtifactName: TestResults_Windows_$(_BuildConfig)_Attempt$(System.JobAttempt) - condition: and(always(), eq(variables.HasTestResults, 'true')) + condition: and(always(), ne(variables._XmlDocsOnly, 'true'), eq(variables.HasTestResults, 'true')) # The integration suite only creates artifacts/tmp/$(_BuildConfig)/testsuite once it runs, and # CopyFiles@2 hard-fails when SourceFolder does not exist. A Test failure early enough to skip the @@ -815,7 +838,7 @@ stages: - job: WindowsAppModel displayName: Windows application-model acceptance dependsOn: DetectChanges - condition: and(succeeded(), or(ne(dependencies.DetectChanges.outputs['detect.hasProductChanges'], 'false'), ne(dependencies.DetectChanges.outputs['detect.hasSamplesChanges'], 'false'))) + condition: and(succeeded(), ne(dependencies.DetectChanges.outputs['detect.xmlDocsOnly'], 'true'), or(ne(dependencies.DetectChanges.outputs['detect.hasProductChanges'], 'false'), ne(dependencies.DetectChanges.outputs['detect.hasSamplesChanges'], 'false'))) timeoutInMinutes: 120 pool: name: NetCore-Public @@ -836,7 +859,7 @@ stages: - job: Linux dependsOn: DetectChanges - condition: and(succeededOrFailed(), ne(dependencies.DetectChanges.outputs['detect.hasProductChanges'], 'false')) + condition: and(succeededOrFailed(), ne(dependencies.DetectChanges.outputs['detect.xmlDocsOnly'], 'true'), ne(dependencies.DetectChanges.outputs['detect.hasProductChanges'], 'false')) timeoutInMinutes: 90 pool: name: NetCore-Public @@ -864,7 +887,7 @@ stages: - job: MacOS dependsOn: DetectChanges - condition: and(succeededOrFailed(), ne(dependencies.DetectChanges.outputs['detect.hasProductChanges'], 'false')) + condition: and(succeededOrFailed(), ne(dependencies.DetectChanges.outputs['detect.xmlDocsOnly'], 'true'), ne(dependencies.DetectChanges.outputs['detect.hasProductChanges'], 'false')) # macOS agents are historically slow and flaky, so this job is intentionally non-blocking for PRs. # Rather than continueOnError (which yields "PartiallySucceeded" and is still blocked by Azure Repos # build-validation policies), every macOS command appends $(_MacOSNonBlockingTrailer) so a failed diff --git a/eng/classify-xml-doc-change.ps1 b/eng/classify-xml-doc-change.ps1 new file mode 100644 index 0000000000..1ffc1bc8fc --- /dev/null +++ b/eng/classify-xml-doc-change.ps1 @@ -0,0 +1,390 @@ +[CmdletBinding(DefaultParameterSetName = "GitDiff")] +param( + [Parameter(Mandatory, ParameterSetName = "GitDiff")] + [string]$Base, + + [Parameter(Mandatory, ParameterSetName = "GitDiff")] + [string]$Head, + + [Parameter(ParameterSetName = "GitDiff")] + [string]$Repository, + + [Parameter(Mandatory, ParameterSetName = "SelfTest")] + [switch]$SelfTest +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false + +$roslynAssemblies = @( + (Join-Path $PSHOME "Microsoft.CodeAnalysis.dll"), + (Join-Path $PSHOME "Microsoft.CodeAnalysis.CSharp.dll") +) +foreach ($assembly in $roslynAssemblies) { + if (-not (Test-Path -LiteralPath $assembly -PathType Leaf)) { + throw "The PowerShell Roslyn assembly '$assembly' is unavailable." + } +} + +Add-Type -Path $roslynAssemblies + +function Get-CSharpSyntaxKind { + param([Microsoft.CodeAnalysis.SyntaxTrivia]$Trivia) + + return [Microsoft.CodeAnalysis.CSharp.SyntaxKind]$Trivia.RawKind +} + +function Get-ComparedTrivia { + param( + [Microsoft.CodeAnalysis.SyntaxNode]$Root, + [switch]$Documentation + ) + + $descendIntoChildren = [Func[Microsoft.CodeAnalysis.SyntaxNode, bool]] { $true } + $result = [System.Collections.Generic.List[string]]::new() + + $tokenIndex = 0 + foreach ($token in $Root.DescendantTokens($descendIntoChildren, $false)) { + foreach ($side in @("Leading", "Trailing")) { + $triviaList = if ($side -eq "Leading") { $token.LeadingTrivia } else { $token.TrailingTrivia } + foreach ($trivia in $triviaList) { + $kind = Get-CSharpSyntaxKind $trivia + $isDocumentation = $kind -in @( + [Microsoft.CodeAnalysis.CSharp.SyntaxKind]::SingleLineDocumentationCommentTrivia, + [Microsoft.CodeAnalysis.CSharp.SyntaxKind]::MultiLineDocumentationCommentTrivia + ) + + if ($Documentation -ne $isDocumentation) { + continue + } + + if (-not $Documentation -and $kind -in @( + [Microsoft.CodeAnalysis.CSharp.SyntaxKind]::WhitespaceTrivia, + [Microsoft.CodeAnalysis.CSharp.SyntaxKind]::EndOfLineTrivia + )) { + continue + } + + $text = $trivia.ToFullString() + $result.Add("${tokenIndex}:${side}:$($trivia.RawKind):$($text.Length):$text") + } + } + + $tokenIndex++ + } + + return $result +} + +function Test-SequenceEqual { + param( + [string[]]$Left, + [string[]]$Right + ) + + if ($Left.Count -ne $Right.Count) { + return $false + } + + for ($index = 0; $index -lt $Left.Count; $index++) { + if ($Left[$index] -cne $Right[$index]) { + return $false + } + } + + return $true +} + +function Test-XmlDocOnlyTextChange { + param( + [string]$OldText, + [string]$NewText + ) + + if ($OldText -ceq $NewText) { + return $false + } + + $parseOptions = [Microsoft.CodeAnalysis.CSharp.CSharpParseOptions]::Default.WithDocumentationMode( + [Microsoft.CodeAnalysis.DocumentationMode]::Diagnose) + $oldTree = [Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree]::ParseText($OldText, $parseOptions) + $newTree = [Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree]::ParseText($NewText, $parseOptions) + + $cancellationToken = [System.Threading.CancellationToken]::None + $hasParseErrors = + @($oldTree.GetDiagnostics($cancellationToken) | Where-Object Severity -eq Error).Count -ne 0 -or + @($newTree.GetDiagnostics($cancellationToken) | Where-Object Severity -eq Error).Count -ne 0 + if ($hasParseErrors) { + return $false + } + + $oldRoot = $oldTree.GetRoot() + $newRoot = $newTree.GetRoot() + if (-not [Microsoft.CodeAnalysis.CSharp.SyntaxFactory]::AreEquivalent($oldRoot, $newRoot)) { + return $false + } + + $oldNonDocumentationTrivia = @(Get-ComparedTrivia $oldRoot) + $newNonDocumentationTrivia = @(Get-ComparedTrivia $newRoot) + if (-not (Test-SequenceEqual $oldNonDocumentationTrivia $newNonDocumentationTrivia)) { + return $false + } + + $oldDocumentation = @(Get-ComparedTrivia $oldRoot -Documentation) + $newDocumentation = @(Get-ComparedTrivia $newRoot -Documentation) + if (@($oldDocumentation + $newDocumentation | Where-Object { $_ -cmatch '<\s*include\b' }).Count -ne 0) { + return $false + } + + return -not (Test-SequenceEqual $oldDocumentation $newDocumentation) +} + +function Test-EligiblePath { + param([string]$Path) + + return ( + [System.IO.Path]::GetExtension($Path) -ceq ".cs" -and + -not $Path.StartsWith("samples/", [System.StringComparison]::Ordinal)) +} + +function Invoke-Git { + param( + [string]$Repository, + [string[]]$Arguments + ) + + $output = @(& git -C $Repository @Arguments 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "git $($Arguments -join ' ') failed: $($output -join [Environment]::NewLine)" + } + + return $output +} + +function Get-GitFileText { + param( + [string]$Repository, + [string]$Revision, + [string]$Path + ) + + return (Invoke-Git $Repository @("show", "--no-textconv", "${Revision}:$Path")) -join "`n" +} + +function Test-GitDiff { + param( + [string]$BaseRevision, + [string]$HeadRevision, + [string]$Repository + ) + + if ([string]::IsNullOrEmpty($Repository)) { + $repositoryOutput = @(Invoke-Git $PSScriptRoot @("rev-parse", "--show-toplevel")) + $Repository = $repositoryOutput[0] + } + + $changes = @(Invoke-Git $Repository @( + "diff", + "--name-status", + "--no-renames", + "--diff-filter=ACDMRTUXB", + $BaseRevision, + $HeadRevision, + "--" + )) + + if ($changes.Count -eq 0) { + return $false + } + + foreach ($change in $changes) { + $status, $path = $change -split "`t", 2 + if ($status -ne "M" -or -not (Test-EligiblePath $path)) { + return $false + } + + $oldText = Get-GitFileText $Repository $BaseRevision $path + $newText = Get-GitFileText $Repository $HeadRevision $path + if (-not (Test-XmlDocOnlyTextChange $oldText $newText)) { + return $false + } + } + + return $true +} + +function Invoke-GitDiffSelfTest { + $repository = Join-Path ([System.IO.Path]::GetTempPath()) "testfx-xml-doc-classifier-$([System.Guid]::NewGuid().ToString('N'))" + [System.IO.Directory]::CreateDirectory($repository) | Out-Null + + try { + $null = Invoke-Git $repository @("init", "--quiet") + $null = Invoke-Git $repository @("config", "user.name", "XML documentation classifier") + $null = Invoke-Git $repository @("config", "user.email", "classifier@example.invalid") + + $firstPath = Join-Path $repository "First.cs" + $secondPath = Join-Path $repository "Second.cs" + [System.IO.File]::WriteAllText($firstPath, "/// old first`nclass First { }`n") + [System.IO.File]::WriteAllText($secondPath, "/// old second`nclass Second { int Value => 1; }`n") + $null = Invoke-Git $repository @("add", "--all") + $null = Invoke-Git $repository @("commit", "--quiet", "-m", "Base") + $baseRevision = @(Invoke-Git $repository @("rev-parse", "HEAD"))[0] + + [System.IO.File]::WriteAllText($firstPath, "/// new first`nclass First { }`n") + [System.IO.File]::WriteAllText($secondPath, "/// new second`nclass Second { int Value => 1; }`n") + $null = Invoke-Git $repository @("add", "--all") + $null = Invoke-Git $repository @("commit", "--quiet", "-m", "Documentation") + $documentationRevision = @(Invoke-Git $repository @("rev-parse", "HEAD"))[0] + if (-not (Test-GitDiff $baseRevision $documentationRevision $repository)) { + throw "Git self-test expected a two-file documentation-only diff to classify as true." + } + + [System.IO.File]::WriteAllText($firstPath, "/// newest first`nclass First { }`n") + [System.IO.File]::WriteAllText($secondPath, "/// new second`nclass Second { int Value => 2; }`n") + $null = Invoke-Git $repository @("add", "--all") + $null = Invoke-Git $repository @("commit", "--quiet", "-m", "Mixed") + $mixedRevision = @(Invoke-Git $repository @("rev-parse", "HEAD"))[0] + if (Test-GitDiff $documentationRevision $mixedRevision $repository) { + throw "Git self-test expected a mixed documentation and code diff to classify as false." + } + + $addedPath = Join-Path $repository "Added.cs" + [System.IO.File]::WriteAllText($addedPath, "/// added`nclass Added { }`n") + $null = Invoke-Git $repository @("add", "--all") + $null = Invoke-Git $repository @("commit", "--quiet", "-m", "Added") + $addedRevision = @(Invoke-Git $repository @("rev-parse", "HEAD"))[0] + if (Test-GitDiff $mixedRevision $addedRevision $repository) { + throw "Git self-test expected an added file to classify as false." + } + + [System.IO.File]::Delete($addedPath) + $null = Invoke-Git $repository @("add", "--all") + $null = Invoke-Git $repository @("commit", "--quiet", "-m", "Deleted") + $deletedRevision = @(Invoke-Git $repository @("rev-parse", "HEAD"))[0] + if (Test-GitDiff $addedRevision $deletedRevision $repository) { + throw "Git self-test expected a deleted file to classify as false." + } + } + finally { + Remove-Item -LiteralPath $repository -Recurse -Force + } +} + +function Invoke-SelfTest { + $cases = @( + @{ + Name = "XML documentation content" + Expected = $true + Old = "class C {`n /// old`n void M() { }`n}" + New = "class C {`n /// new`n void M() { }`n}" + }, + @{ + Name = "Added XML documentation line" + Expected = $true + Old = "class C {`n /// first`n void M() { }`n}" + New = "class C {`n /// first`n /// second`n void M() { }`n}" + }, + @{ + Name = "Removed XML documentation" + Expected = $true + Old = "class C {`n /// removed`n void M() { }`n}" + New = "class C {`n void M() { }`n}" + }, + @{ + Name = "Multiline XML documentation" + Expected = $true + Old = "class C {`n /** old */`n void M() { }`n}" + New = "class C {`n /** new */`n void M() { }`n}" + }, + @{ + Name = "XML documentation include" + Expected = $false + Old = "class C {`n /// `n void M() { }`n}" + New = "class C {`n /// `n void M() { }`n}" + }, + @{ + Name = "Ordinary comment" + Expected = $false + Old = "class C {`n // old`n void M() { }`n}" + New = "class C {`n // new`n void M() { }`n}" + }, + @{ + Name = "Executable code" + Expected = $false + Old = "class C { int P => 1; }" + New = "class C { int P => 2; }" + }, + @{ + Name = "XML-looking raw string content" + Expected = $false + Old = "class C { string P => """"""`n/// old`n""""""; }" + New = "class C { string P => """"""`n/// new`n""""""; }" + }, + @{ + Name = "Mixed XML and ordinary comments" + Expected = $false + Old = "class C {`n /// old`n // old`n void M() { }`n}" + New = "class C {`n /// new`n // new`n void M() { }`n}" + }, + @{ + Name = "Whitespace only" + Expected = $false + Old = "class C { void M() { } }" + New = "class C { void M() { } }" + }, + @{ + Name = "Identical files" + Expected = $false + Old = "class C { }" + New = "class C { }" + }, + @{ + Name = "Mixed XML documentation and preprocessor directive" + Expected = $false + Old = "#if DEBUG`n/// old`nclass C { }`n#endif" + New = "#if RELEASE`n/// new`nclass C { }`n#endif" + }, + @{ + Name = "Relocated preprocessor directive" + Expected = $false + Old = "#nullable disable`n/// old`nclass C { }`nclass D { }" + New = "/// new`nclass C { }`n#nullable disable`nclass D { }" + } + ) + + foreach ($case in $cases) { + $actual = Test-XmlDocOnlyTextChange $case.Old $case.New + if ($actual -ne $case.Expected) { + throw "Self-test '$($case.Name)' expected '$($case.Expected)' but got '$actual'." + } + } + + foreach ($pathCase in @( + @{ Path = "src/Product.cs"; Expected = $true }, + @{ Path = "samples/public/Sample.cs"; Expected = $false }, + @{ Path = "samples/internal/Sample.cs"; Expected = $false }, + @{ Path = "src/Product.vb"; Expected = $false } + )) { + $actual = Test-EligiblePath $pathCase.Path + if ($actual -ne $pathCase.Expected) { + throw "Path self-test '$($pathCase.Path)' expected '$($pathCase.Expected)' but got '$actual'." + } + } + + Invoke-GitDiffSelfTest + + Write-Output "XML documentation change classifier self-tests passed." +} + +if ($SelfTest) { + Invoke-SelfTest + exit 0 +} + +try { + Write-Output ((Test-GitDiff $Base $Head $Repository).ToString().ToLowerInvariant()) +} +catch { + [Console]::Error.WriteLine("##vso[task.logissue type=warning]XML documentation change classification failed; running full validation. $($_.Exception.Message)") + Write-Output "false" +} diff --git a/eng/pipelines/steps/test-windows-configuration-tests.yml b/eng/pipelines/steps/test-windows-configuration-tests.yml index 611341cde9..7a818b8925 100644 --- a/eng/pipelines/steps/test-windows-configuration-tests.yml +++ b/eng/pipelines/steps/test-windows-configuration-tests.yml @@ -30,7 +30,7 @@ steps: dotnet test -c $(_BuildConfig) --no-build -bl:$(BUILD.SOURCESDIRECTORY)\artifacts\TestResults\$(_BuildConfig)\TestStep.binlog -p:UsingDotNetTest=true -p:TestingPlatformCaptureOutput=false name: Test displayName: Test - condition: and(succeeded(), eq(variables._BuildConfig, 'Release')) + condition: and(succeeded(), eq(variables._BuildConfig, 'Release'), ne(variables._XmlDocsOnly, 'true')) env: # Secret variables are not automatically exposed to scripts. Fork PR builds do not receive this token, # so report-azdo history queries no-op there; trusted branch builds exercise them end-to-end. @@ -48,14 +48,14 @@ steps: "affected-tests" | "${{ parameters.affectedTestsCacheVersion }}" | "$(Agent.OS)" | "$(Agent.OSArchitecture)" | "$(_BuildConfig)" path: '$(Pipeline.Workspace)\affected-test-map' cacheHitVar: AffectedTestsMapCacheRestored - condition: and(succeeded(), eq(variables._BuildConfig, 'Release')) + condition: and(succeeded(), eq(variables._BuildConfig, 'Release'), ne(variables._XmlDocsOnly, 'true')) - script: | echo ##vso[task.setvariable variable=PublishCoverageReport]true dotnet test -c $(_BuildConfig) --no-build -bl:$(BUILD.SOURCESDIRECTORY)\artifacts\TestResults\$(_BuildConfig)\TestStep.binlog -p:UsingDotNetTest=true -p:TestingPlatformCaptureOutput=false --collect-test-map name: Test displayName: Test and collect affected-test map - condition: and(succeeded(), eq(variables._BuildConfig, 'Release')) + condition: and(succeeded(), eq(variables._BuildConfig, 'Release'), ne(variables._XmlDocsOnly, 'true')) env: DOTNET_CLI_ENABLE_AFFECTED_TESTS: 1 SYSTEM_ACCESSTOKEN: $(System.AccessToken) @@ -69,7 +69,7 @@ steps: "affected-tests" | "${{ parameters.affectedTestsCacheVersion }}" | "$(Agent.OS)" | "$(Agent.OSArchitecture)" | "$(_BuildConfig)" path: '$(Pipeline.Workspace)\affected-test-map' cacheHitVar: AffectedTestsMapCacheRestored - condition: and(succeeded(), eq(variables._BuildConfig, 'Release'), eq(variables['Build.Reason'], 'PullRequest')) + condition: and(succeeded(), eq(variables._BuildConfig, 'Release'), ne(variables._XmlDocsOnly, 'true'), eq(variables['Build.Reason'], 'PullRequest')) - pwsh: | dotnet test -c $(_BuildConfig) --no-build -bl:$(BUILD.SOURCESDIRECTORY)\artifacts\TestResults\$(_BuildConfig)\TestStep.binlog -p:UsingDotNetTest=true -p:TestingPlatformCaptureOutput=false --affected-tests @@ -88,7 +88,7 @@ steps: exit 0 name: TestAffected displayName: Test affected changes - condition: and(succeeded(), eq(variables._BuildConfig, 'Release'), eq(variables['Build.Reason'], 'PullRequest'), ne(variables['AffectedTestsMapCacheRestored'], 'false')) + condition: and(succeeded(), eq(variables._BuildConfig, 'Release'), ne(variables._XmlDocsOnly, 'true'), eq(variables['Build.Reason'], 'PullRequest'), ne(variables['AffectedTestsMapCacheRestored'], 'false')) env: DOTNET_CLI_ENABLE_AFFECTED_TESTS: 1 SYSTEM_ACCESSTOKEN: $(System.AccessToken) @@ -108,7 +108,7 @@ steps: exit $LASTEXITCODE name: Test displayName: Test (affected-test fallback) - condition: and(succeeded(), eq(variables._BuildConfig, 'Release'), or(ne(variables['Build.Reason'], 'PullRequest'), eq(variables['AffectedTestsMapCacheRestored'], 'false'), ne(variables['AffectedTestsSucceeded'], 'true'))) + condition: and(succeeded(), eq(variables._BuildConfig, 'Release'), ne(variables._XmlDocsOnly, 'true'), or(ne(variables['Build.Reason'], 'PullRequest'), eq(variables['AffectedTestsMapCacheRestored'], 'false'), ne(variables['AffectedTestsSucceeded'], 'true'))) env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) @@ -134,7 +134,7 @@ steps: /p:MSBuildCacheEnabled=false exit $LASTEXITCODE displayName: Prepare cached Debug test runtime - condition: and(succeeded(), eq(variables._BuildConfig, 'Debug'), eq(variables['MSBuildCacheBuildSucceeded'], 'true')) + condition: and(succeeded(), eq(variables._BuildConfig, 'Debug'), ne(variables._XmlDocsOnly, 'true'), eq(variables['MSBuildCacheBuildSucceeded'], 'true')) # The Debug leg exists to exercise DEBUG-conditional product code and tests. The Release integration suite # already creates child test assets in both configurations, so repeating that suite in Debug is redundant. @@ -142,7 +142,7 @@ steps: - script: dotnet test --no-build --test-modules "artifacts/bin/*UnitTests*/$(_BuildConfig)/**/*UnitTests.exe" --results-directory $(BUILD.SOURCESDIRECTORY)\artifacts\TestResults\$(_BuildConfig) $(_DebugUnitTestReportArgs) --diagnostic --diagnostic-output-directory $(BUILD.SOURCESDIRECTORY)\artifacts\log\$(_BuildConfig) --diagnostic-verbosity trace name: TestDebug displayName: Test (unit tests only) - condition: and(succeeded(), eq(variables._BuildConfig, 'Debug')) + condition: and(succeeded(), eq(variables._BuildConfig, 'Debug'), ne(variables._XmlDocsOnly, 'true')) env: SYSTEM_ACCESSTOKEN: $(System.AccessToken)