Summary
Modifying files inside an Azure DevOps Build Validation pipeline triggers a new run, because the pipeline’s own push creates a new commit on the PR branch. Even when the commit message contains [skip ci], Azure DevOps treats the push as a new PR update and starts the validation pipeline again, leading to an infinite loop.
Root Cause
- The Build Validation pipeline is configured to run on every PR update.
- Inside the pipeline we checkout the PR branch, modify it, and push the change back.
- Azure DevOps interprets that push as a new PR update, which satisfies the trigger condition and starts the pipeline again.
[skip ci]only works for classic CI triggers; it does not suppress Build Validation runs.
Why This Happens in Real Systems
- Push‑trigger coupling: Build Validation is a gate that re‑evaluates the PR any time the source branch changes.
- Credential scope:
persistCredentials: truegives the pipeline permission to push, so the push is indistinguishable from a developer’s push. - Separate trigger models: Azure DevOps has distinct mechanisms for CI triggers (
trigger:/pr:) and for branch policies (Build Validation). The skip‑ci flag only affects CI triggers.
Real-World Impact
- Infinite pipeline loops that consume build minutes and queue space.
- Stalled PR merges because the required validation never reaches a successful state.
- Noise in build history, making it hard to trace actual test failures.
- Potential rate‑limit or quota exhaustion on the hosted agent pool.
Example or Code (if necessary and relevant)
trigger: none
pr: none # disables CI triggers; Build Validation still runs via policy
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
fetchDepth: 0
fetchTags: true
persistCredentials: true
- bash: |
BRANCH_NAME=$(echo $SYSTEM_PULLREQUEST_SOURCEBRANCH | sed 's|refs/heads/||')
git checkout $BRANCH_NAME
cz bump --yes --prerelease rc
git push --follow-tags
displayName: 'Prepare RC version'
How Senior Engineers Fix It
- Separate pipelines: Use one pipeline only for version bumping (triggered manually or on merge to
main), and a lightweight validation pipeline that does not push. - Disable self‑push: Remove
persistCredentialsand run the version bump in a service account job that uses a different repository (e.g., a release‑candidate branch). - Use pipeline variables: Generate a temporary RC version string (e.g.,
$(Build.SourceBranchName)-rc$(Build.BuildId)) without committing it. - Leverage artifacts: Build the RC package and publish it as an artifact or to a NuGet/npm feed, rather than committing the bump back to the PR branch.
- Adjust branch policy: Set the Build Validation to run only on PR creation (
trigger: none+pr: none) and add a separate status check that runs on pushes to the PR branch if needed. - Explicitly skip validation: Use the
Build.Reasonvariable to abort the pipeline whenBuild.Reason == 'PullRequest' && variables['Build.SourceVersionMessage'] contains '[skip ci]'.
Why Juniors Miss It
- They assume
[skip ci]works universally, not recognizing the split between CI triggers and Build Validation policies. - They often mix responsibilities in a single pipeline (testing + version bump + push), overlooking that a pipeline should be idempotent and not modify its own trigger source.
- Lack of familiarity with branch‑policy mechanics leads them to think disabling
trigger:is enough, ignoring the policy‑driven re‑run. - They may not consider alternative flows (artifacts, release branches) and default to the simplest “push from the pipeline” approach.
Bottom line: Keep pipelines that run as part of PR validation read‑only; perform any commit‑or‑push actions in a separate, explicitly‑triggered pipeline or external service.