User Safety: safe

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: true gives 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 persistCredentials and 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.Reason variable to abort the pipeline when Build.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.

Leave a Comment