From ca836df6f1a1e6febf88e72384677d85eb373544 Mon Sep 17 00:00:00 2001 From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com> Date: Thu, 21 May 2026 11:58:11 -0300 Subject: [PATCH 1/2] Skip redundant PR triggers (Draft<->Open state transitions) Add a gate stage to PR pipelines that detects when a build was triggered by a state-only PR transition (Draft<->Open) rather than meaningful changes. The gate queries the Azure DevOps builds API for the most recent successful build of the same PR and compares the merge commit SHA. If unchanged and a prior build succeeded, downstream stages are skipped via condition cascade. - New: eng/pipelines/stages/detect-redundant-trigger-stage.yml - Modified: dotnet-sqlclient-ci-core.yml (skipRedundantTrigger parameter) - Modified: both sqlclient-pr-*-pipeline.yml (pass skipRedundantTrigger: true) --- eng/pipelines/dotnet-sqlclient-ci-core.yml | 30 +++++ .../sqlclient-pr-package-ref-pipeline.yml | 1 + .../sqlclient-pr-project-ref-pipeline.yml | 1 + .../stages/detect-redundant-trigger-stage.yml | 109 ++++++++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 eng/pipelines/stages/detect-redundant-trigger-stage.yml diff --git a/eng/pipelines/dotnet-sqlclient-ci-core.yml b/eng/pipelines/dotnet-sqlclient-ci-core.yml index f5f2269494..fcf15f8af0 100644 --- a/eng/pipelines/dotnet-sqlclient-ci-core.yml +++ b/eng/pipelines/dotnet-sqlclient-ci-core.yml @@ -9,6 +9,13 @@ parameters: type: boolean default: false + # When true, add a gate stage that detects redundant PR triggers (e.g. Draft <-> Open state + # transitions with no new commits). When a redundant trigger is detected, all subsequent stages + # are skipped and the pipeline succeeds without performing any work. + - name: skipRedundantTrigger + type: boolean + default: false + # The target frameworks to build and run tests for on Windows. # # These are _not_ the target frameworks to build the driver packages for. @@ -123,6 +130,29 @@ variables: value: SqlServer.Artifacts stages: + # When skipRedundantTrigger is enabled, add a gate stage that detects PR triggers caused by + # state-only transitions (Draft <-> Open). If the trigger is redundant, the no-op stage's + # condition evaluates to false (skipped), which causes secrets_stage to implicitly skip via + # its default succeeded() condition, cascading to all downstream stages. + - ${{ if eq(parameters.skipRedundantTrigger, true) }}: + - template: /eng/pipelines/stages/detect-redundant-trigger-stage.yml@self + parameters: + debug: ${{ parameters.debug }} + + - stage: skip_redundant_trigger_stage + displayName: Skip Redundant Trigger + dependsOn: detect_redundant_trigger_stage + condition: eq(dependencies.detect_redundant_trigger_stage.outputs['detect_redundant_trigger.detect.meaningful'], 'true') + jobs: + - job: proceed + displayName: Build is meaningful + pool: + vmImage: ubuntu-latest + steps: + - checkout: none + - bash: echo "PR Trigger gate passed — proceeding with build and test." + displayName: Confirm + # Generate secrets used throughout the pipeline. - template: /eng/pipelines/stages/generate-secrets-ci-stage.yml@self parameters: diff --git a/eng/pipelines/sqlclient-pr-package-ref-pipeline.yml b/eng/pipelines/sqlclient-pr-package-ref-pipeline.yml index be7e2e9e3b..5cbde97772 100644 --- a/eng/pipelines/sqlclient-pr-package-ref-pipeline.yml +++ b/eng/pipelines/sqlclient-pr-package-ref-pipeline.yml @@ -130,6 +130,7 @@ extends: buildPlatforms: ${{ parameters.buildPlatforms }} referenceType: Package debug: ${{ parameters.debug }} + skipRedundantTrigger: true targetFrameworks: ${{ parameters.targetFrameworks }} targetFrameworksUnix: ${{ parameters.targetFrameworksUnix }} testJobTimeout: ${{ parameters.testJobTimeout }} diff --git a/eng/pipelines/sqlclient-pr-project-ref-pipeline.yml b/eng/pipelines/sqlclient-pr-project-ref-pipeline.yml index 30950ec21c..77aa782c49 100644 --- a/eng/pipelines/sqlclient-pr-project-ref-pipeline.yml +++ b/eng/pipelines/sqlclient-pr-project-ref-pipeline.yml @@ -130,6 +130,7 @@ extends: buildPlatforms: ${{ parameters.buildPlatforms }} referenceType: Project debug: ${{ parameters.debug }} + skipRedundantTrigger: true targetFrameworks: ${{ parameters.targetFrameworks }} targetFrameworksUnix: ${{ parameters.targetFrameworksUnix }} testJobTimeout: ${{ parameters.testJobTimeout }} diff --git a/eng/pipelines/stages/detect-redundant-trigger-stage.yml b/eng/pipelines/stages/detect-redundant-trigger-stage.yml new file mode 100644 index 0000000000..3480fcf18c --- /dev/null +++ b/eng/pipelines/stages/detect-redundant-trigger-stage.yml @@ -0,0 +1,109 @@ +#################################################################################################### +# Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this +# file to you under the MIT license. See the LICENSE file in the project root for more information. +#################################################################################################### + +# This stage gates PR pipeline runs to avoid redundant builds triggered by state-only transitions +# (e.g. Draft <-> Open). It compares the current merge commit (Build.SourceVersion) against the +# most recent successful build for the same PR in this pipeline definition. +# +# If the merge commit is unchanged and the prior build succeeded, the build is considered redundant +# and the output variable 'meaningful' is set to 'false'. Downstream stages should check this +# variable and skip when it is 'false'. +# +# Output variable reference (for use in stage conditions): +# +# dependencies.detect_redundant_trigger_stage.outputs['detect_redundant_trigger.detect.meaningful'] +# +# This stage defines: +# +# Stage name: detect_redundant_trigger_stage +# Job name: detect_redundant_trigger +# Step name: detect +# Variable: meaningful (true|false) + +parameters: + + # True to emit debug information. + - name: debug + type: boolean + default: false + +stages: + + - stage: detect_redundant_trigger_stage + displayName: Detect Redundant Trigger + jobs: + + - job: detect_redundant_trigger + displayName: Detect Redundant Trigger + pool: + vmImage: ubuntu-latest + + steps: + + - checkout: none + + - bash: | + set -euo pipefail + + # The current merge commit SHA for this PR build. + CURRENT_SOURCE_VERSION="$(Build.SourceVersion)" + + # The Azure DevOps REST API base URL. + ORG_URL="$(System.CollectionUri)" + PROJECT="$(System.TeamProject)" + DEFINITION_ID="$(System.DefinitionId)" + BUILD_ID="$(Build.BuildId)" + + # The branch name used for PR builds (refs/pull/{number}/merge). + BRANCH="$(Build.SourceBranch)" + + echo "Current source version: $CURRENT_SOURCE_VERSION" + echo "Branch: $BRANCH" + echo "Definition ID: $DEFINITION_ID" + echo "Build ID: $BUILD_ID" + + # Query the most recent successful build for this PR in this pipeline, + # excluding the current build. + API_URL="${ORG_URL}${PROJECT}/_apis/build/builds" + API_URL="${API_URL}?definitions=${DEFINITION_ID}" + API_URL="${API_URL}&branchName=${BRANCH}" + API_URL="${API_URL}&resultFilter=succeeded" + API_URL="${API_URL}&\$top=5" + API_URL="${API_URL}&queryOrder=finishTimeDescending" + API_URL="${API_URL}&api-version=7.1" + + RESPONSE=$(curl -s -H "Authorization: Bearer $(System.AccessToken)" "$API_URL") + + if [[ "${{ parameters.debug }}" == "True" ]]; then + echo "API Response:" + echo "$RESPONSE" | head -c 2000 + fi + + # Find the first successful build that is not the current build. + LAST_SUCCESSFUL_VERSION=$(echo "$RESPONSE" | \ + jq -r --argjson buildId "$BUILD_ID" \ + '[.value[] | select(.id != $buildId)] | .[0].sourceVersion // empty') + + echo "Last successful source version: ${LAST_SUCCESSFUL_VERSION:-}" + + if [[ -n "$LAST_SUCCESSFUL_VERSION" && "$LAST_SUCCESSFUL_VERSION" == "$CURRENT_SOURCE_VERSION" ]]; then + echo "" + echo "##[section]Build is REDUNDANT — source version unchanged and prior build succeeded." + echo "This is likely a Draft/Open state transition. Skipping build." + echo "##vso[task.setvariable variable=meaningful;isOutput=true]false" + else + echo "" + echo "##[section]Build is MEANINGFUL — proceeding." + if [[ -z "$LAST_SUCCESSFUL_VERSION" ]]; then + echo " Reason: No prior successful build found for this PR." + elif [[ "$LAST_SUCCESSFUL_VERSION" != "$CURRENT_SOURCE_VERSION" ]]; then + echo " Reason: Source version changed (new commits or target branch update)." + fi + echo "##vso[task.setvariable variable=meaningful;isOutput=true]true" + fi + name: detect + displayName: Check for redundant PR trigger + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) From ca98ab30a3a34daefeeafd499c1b186e3b35aec0 Mon Sep 17 00:00:00 2001 From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com> Date: Thu, 21 May 2026 12:47:51 -0300 Subject: [PATCH 2/2] Address Copilot review feedback on redundant trigger gate - Use $SYSTEM_ACCESSTOKEN env var instead of $(System.AccessToken) macro expansion in curl to avoid accidental logging/expansion - Add HTTP status code validation with safe fallback (meaningful=true) when the Builds API is unavailable or returns errors - Add Build.Reason check to skip gate for non-PR triggers (manual reruns) - Move debug output to a separate conditional step using template-time ${{ if eq(parameters.debug, true) }} instead of runtime string comparison --- .../stages/detect-redundant-trigger-stage.yml | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/eng/pipelines/stages/detect-redundant-trigger-stage.yml b/eng/pipelines/stages/detect-redundant-trigger-stage.yml index 3480fcf18c..2f02133c16 100644 --- a/eng/pipelines/stages/detect-redundant-trigger-stage.yml +++ b/eng/pipelines/stages/detect-redundant-trigger-stage.yml @@ -47,6 +47,13 @@ stages: - bash: | set -euo pipefail + # If this is not a PR-triggered run (e.g., manual queue), always proceed. + if [[ "$(Build.Reason)" != "PullRequest" ]]; then + echo "##[section]Build is MEANINGFUL — not a PR trigger (Build.Reason=$(Build.Reason))." + echo "##vso[task.setvariable variable=meaningful;isOutput=true]true" + exit 0 + fi + # The current merge commit SHA for this PR build. CURRENT_SOURCE_VERSION="$(Build.SourceVersion)" @@ -74,13 +81,19 @@ stages: API_URL="${API_URL}&queryOrder=finishTimeDescending" API_URL="${API_URL}&api-version=7.1" - RESPONSE=$(curl -s -H "Authorization: Bearer $(System.AccessToken)" "$API_URL") + # Call the API using the mapped env var (avoids macro expansion/logging). + # On HTTP error, default to meaningful=true so the build proceeds. + HTTP_CODE=$(curl -s -o /tmp/api_response.json -w "%{http_code}" \ + -H "Authorization: Bearer $SYSTEM_ACCESSTOKEN" "$API_URL") - if [[ "${{ parameters.debug }}" == "True" ]]; then - echo "API Response:" - echo "$RESPONSE" | head -c 2000 + if [[ "$HTTP_CODE" -lt 200 || "$HTTP_CODE" -ge 300 ]]; then + echo "##[warning]Builds API returned HTTP $HTTP_CODE — defaulting to meaningful build." + echo "##vso[task.setvariable variable=meaningful;isOutput=true]true" + exit 0 fi + RESPONSE=$(cat /tmp/api_response.json) + # Find the first successful build that is not the current build. LAST_SUCCESSFUL_VERSION=$(echo "$RESPONSE" | \ jq -r --argjson buildId "$BUILD_ID" \ @@ -107,3 +120,9 @@ stages: displayName: Check for redundant PR trigger env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) + + - ${{ if eq(parameters.debug, true) }}: + - bash: | + echo "API Response (first 2000 chars):" + head -c 2000 /tmp/api_response.json 2>/dev/null || echo "" + displayName: Debug - Show API Response