diff --git a/.vsts-dotnet-ci.yml b/.vsts-dotnet-ci.yml index 1dfd65105da..4fbcc9ec62c 100644 --- a/.vsts-dotnet-ci.yml +++ b/.vsts-dotnet-ci.yml @@ -1,3 +1,13 @@ +parameters: +# Condition (compile-time constant) reused by every heavy build/test job below. It runs the leg +# UNLESS the EvaluateDocumentationOnlyChange gate succeeded and classified the change as +# documentation/.github-only. Fail-open: if the gate did not succeed we still run the leg. Kept in +# one place and referenced via ${{ parameters.runUnlessDocumentationOnly }} so the long expression +# is authored exactly once instead of copied onto each job. +- name: runUnlessDocumentationOnly + type: string + default: "or(not(succeeded('EvaluateDocumentationOnlyChange')), eq(dependencies.EvaluateDocumentationOnlyChange.outputs['CheckDocumentationOnlyChange.onlyDocChanged'], '0'))" + trigger: branches: include: @@ -20,7 +30,23 @@ variables: value: none jobs: +# Lightweight gate: determine once whether the PR changed only documentation/.github files. +# Every heavy build/test job depends on this and is skipped entirely -- no agent is allocated -- +# when it reports onlyDocChanged=1, so a docs-only PR never waits on the build pools. +- job: EvaluateDocumentationOnlyChange + displayName: "Evaluate documentation-only change" + pool: + ${{ if eq(variables['System.TeamProject'], 'public') }}: + name: NetCore-Public + demands: ImageOverride -equals Build.Ubuntu.2204.Amd64.Open + ${{ if ne(variables['System.TeamProject'], 'public') }}: + vmImage: 'ubuntu-latest' + steps: + - template: azure-pipelines/check-documentation-only-change.yml + - job: BootstrapMSBuildOnFullFrameworkWindows + dependsOn: EvaluateDocumentationOnlyChange + condition: ${{ parameters.runUnlessDocumentationOnly }} variables: coverageArtifactsDir: $(Build.SourcesDirectory)/CoverageStaging coverageReportName: WindowsFullFrameworkCoverage @@ -134,6 +160,8 @@ jobs: ArtifactName: 'Windows-on-full Verify $(System.JobAttempt)' - job: BootstrapMSBuildOnCoreWindows + dependsOn: EvaluateDocumentationOnlyChange + condition: ${{ parameters.runUnlessDocumentationOnly }} displayName: "Windows Core" variables: coverageArtifactsDir: $(Build.SourcesDirectory)/CoverageStaging @@ -222,6 +250,8 @@ jobs: # kept as a separate Linux Core job because the analyzer is more expensive and the regular # bootstrapped CI jobs (which all run /mt now too) do not need it. - job: BootstrapMSBuildWithMTMode + dependsOn: EvaluateDocumentationOnlyChange + condition: ${{ parameters.runUnlessDocumentationOnly }} displayName: "Linux Core Multithreaded Mode" pool: vmImage: 'ubuntu-latest' @@ -252,6 +282,8 @@ jobs: condition: always() - job: FullReleaseOnWindows + dependsOn: EvaluateDocumentationOnlyChange + condition: ${{ parameters.runUnlessDocumentationOnly }} displayName: "Windows Full Release (no bootstrap)" variables: coverageArtifactsDir: $(Build.SourcesDirectory)/CoverageStaging @@ -335,6 +367,8 @@ jobs: condition: eq(variables.onlyDocChanged, 0) - job: CoreBootstrappedOnLinux + dependsOn: EvaluateDocumentationOnlyChange + condition: ${{ parameters.runUnlessDocumentationOnly }} displayName: "Linux Core" pool: ${{ if eq(variables['System.TeamProject'], 'public') }}: @@ -430,6 +464,8 @@ jobs: ArtifactName: 'Linux Verify $(System.JobAttempt)' - job: CoreOnMac + dependsOn: EvaluateDocumentationOnlyChange + condition: ${{ parameters.runUnlessDocumentationOnly }} displayName: "macOS Core" pool: vmImage: 'macOS-latest' @@ -521,13 +557,15 @@ jobs: - job: CodeCoverage displayName: "Code Coverage" dependsOn: + - EvaluateDocumentationOnlyChange - BootstrapMSBuildOnFullFrameworkWindows - BootstrapMSBuildOnCoreWindows - FullReleaseOnWindows - CoreBootstrappedOnLinux - CoreOnMac + condition: and(succeeded(), ${{ parameters.runUnlessDocumentationOnly }}) variables: - onlyDocChanged: $[ dependencies.BootstrapMSBuildOnFullFrameworkWindows.outputs['CheckDocumentationOnlyChange.onlyDocChanged'] ] + onlyDocChanged: $[ dependencies.EvaluateDocumentationOnlyChange.outputs['CheckDocumentationOnlyChange.onlyDocChanged'] ] pool: ${{ if eq(variables['System.TeamProject'], 'public') }}: name: NetCore-Public @@ -590,3 +628,11 @@ jobs: pathToSources: $(Build.SourcesDirectory) condition: eq(variables.onlyDocChanged, 0) - template: /eng/common/templates/jobs/source-build.yml + parameters: + # Skip the source-build leg entirely (no agent allocated) for documentation/.github-only PRs. + defaultManagedPlatform: + name: 'Managed' + container: 'mcr.microsoft.com/dotnet-buildtools/prereqs:centos-stream-10-amd64' + jobProperties: + dependsOn: EvaluateDocumentationOnlyChange + condition: ${{ parameters.runUnlessDocumentationOnly }} diff --git a/azure-pipelines/check-documentation-only-change.yml b/azure-pipelines/check-documentation-only-change.yml index b600c5d2595..0ed9223ab29 100644 --- a/azure-pipelines/check-documentation-only-change.yml +++ b/azure-pipelines/check-documentation-only-change.yml @@ -1,20 +1,42 @@ # Template to check if only non-build files have changed (documentation and .github) # Sets a pipeline variable 'onlyDocChanged' to 1 if only non-build files changed, 0 otherwise # This template should be included at the start of test jobs to determine if tests should run +# +# Diff semantics: +# For PR validation builds Azure DevOps checks out the GitHub-generated merge ref +# (refs/pull//merge). Its first parent (HEAD~1) is the tip of the PR's base branch, +# so 'git diff HEAD HEAD~1' is the FULL cumulative PR diff regardless of how many +# commits the PR contains -- not just the last commit. +# +# Fail-open: +# Detection defaults to onlyDocChanged=0 (run full CI) on any git error or an empty +# diff. This is the safe direction: a detection problem never skips CI for a code +# change, it only ever runs more than strictly necessary. Job-level conditions layer +# a second fail-open on top (they run when this gate job does not succeed). steps: - powershell: | - $changedFiles = git diff --name-only HEAD HEAD~1 - $changedFiles - $onlyDocChanged = 1 - foreach ($file in $changedFiles) { - $isNonBuildFile = ($file -match "^documentation/") -or ($file -match "^\.github/") - if(!$isNonBuildFile) - { - $onlyDocChanged = 0 - break; + $onlyDocChanged = 0 + try { + $changedFiles = git diff --name-only HEAD HEAD~1 + if ($LASTEXITCODE -ne 0) { throw "git diff exited with code $LASTEXITCODE" } + $changedFiles + if ($changedFiles) { + $onlyDocChanged = 1 + foreach ($file in $changedFiles) { + $isNonBuildFile = ($file -match "^documentation/") -or ($file -match "^\.github/") + if (!$isNonBuildFile) { + $onlyDocChanged = 0 + break + } + } } } + catch { + Write-Host "Documentation-only detection failed; defaulting to running full CI. $_" + $onlyDocChanged = 0 + } + Write-Host "onlyDocChanged=$onlyDocChanged" Write-Host "##vso[task.setvariable variable=onlyDocChanged]$onlyDocChanged" Write-Host "##vso[task.setvariable variable=onlyDocChanged;isoutput=true]$onlyDocChanged" name: CheckDocumentationOnlyChange diff --git a/azure-pipelines/quarantine.yml b/azure-pipelines/quarantine.yml index 3b1349ba545..554b6028961 100644 --- a/azure-pipelines/quarantine.yml +++ b/azure-pipelines/quarantine.yml @@ -29,6 +29,15 @@ # targeting `main` (so a fix PR's effect on the quarantined tests is validated on the PR itself), and # manual queue. Consumers that aggregate this data treat rolling and scheduled `main` runs the same # (reasons batchedCI/individualCI/schedule), so the mix of triggers is transparent to them. +parameters: +# Condition (compile-time constant) reused by every quarantine leg. Runs the leg on any non-PR build +# (rolling/batched and scheduled re-validation on main), or -- fail-open -- if the gate did not +# succeed, or when the gate classified the PR as documentation/.github-only. Authored once here and +# referenced via ${{ parameters.runUnlessDocumentationOnly }} instead of copied onto each job. +- name: runUnlessDocumentationOnly + type: string + default: "or(ne(variables['Build.Reason'], 'PullRequest'), not(succeeded('EvaluateDocumentationOnlyChange')), eq(dependencies.EvaluateDocumentationOnlyChange.outputs['CheckDocumentationOnlyChange.onlyDocChanged'], '0'))" + trigger: batch: true branches: @@ -57,8 +66,22 @@ variables: value: false jobs: +# Lightweight gate: determine once whether the PR changed only documentation/.github files. +# The quarantine legs depend on this and are skipped entirely -- no agent is allocated -- for a +# docs-only pull request. Non-PR runs (rolling/batched and scheduled re-validation on main) always +# run, so the quarantined-test signal keeps tracking main regardless of this gate. +- job: EvaluateDocumentationOnlyChange + displayName: "Evaluate documentation-only change" + pool: + name: NetCore-Public + demands: ImageOverride -equals Build.Ubuntu.2204.Amd64.Open + steps: + - template: azure-pipelines/check-documentation-only-change.yml + - job: RunQuarantinedTestsWindows displayName: "Run quarantined tests (Windows)" + dependsOn: EvaluateDocumentationOnlyChange + condition: ${{ parameters.runUnlessDocumentationOnly }} pool: name: NetCore-Public demands: ImageOverride -equals windows.vs2026preview.scout.amd64.open @@ -112,6 +135,8 @@ jobs: - job: RunQuarantinedTestsLinux displayName: "Run quarantined tests (Linux)" + dependsOn: EvaluateDocumentationOnlyChange + condition: ${{ parameters.runUnlessDocumentationOnly }} pool: name: NetCore-Public demands: ImageOverride -equals Build.Ubuntu.2204.Amd64.Open @@ -154,6 +179,8 @@ jobs: - job: RunQuarantinedTestsMac displayName: "Run quarantined tests (macOS)" + dependsOn: EvaluateDocumentationOnlyChange + condition: ${{ parameters.runUnlessDocumentationOnly }} pool: vmImage: 'macOS-latest' timeoutInMinutes: 120