Pipelines

Pipeline Triggers: CI, PR, and Scheduled Builds

Complete guide to Azure DevOps pipeline triggers including CI, PR validation, scheduled cron builds, tag triggers, and cross-pipeline dependencies with practical YAML examples.

Pipeline Triggers: CI, PR, and Scheduled Builds

Overview

Pipeline triggers determine when your pipeline runs, and getting them wrong means either builds that never fire or builds that fire on every commit across every branch, burning through your agent pool hours. Azure DevOps YAML pipelines support four trigger types: CI (continuous integration), PR (pull request validation), scheduled (cron-based), and pipeline resource triggers (cross-pipeline dependencies). This article covers each trigger type in depth, with the YAML syntax, edge cases, and debugging strategies I have used across dozens of production pipelines.

Prerequisites

  • An Azure DevOps organization and project with Repos and Pipelines enabled
  • Basic familiarity with YAML pipeline syntax (stages, jobs, steps)
  • A Git repository hosted in Azure Repos (some trigger behaviors differ for GitHub-hosted repos)
  • Understanding of Git branching workflows (feature branches, release branches, tags)
  • Node.js v18+ installed if running the Node.js examples locally

CI Triggers

CI triggers fire when code is pushed to a branch that matches your filter criteria. This is the most common trigger type and the one most teams configure first.

Basic Branch Filtering

The simplest CI trigger watches one or more branches:

trigger:
  branches:
    include:
      - main
      - develop
      - release/*

This pipeline runs when commits land on main, develop, or any branch matching release/*. Pushes to feature branches, hotfix branches, or anything else are ignored.

You can also exclude specific branches:

trigger:
  branches:
    include:
      - '*'
    exclude:
      - experimental/*
      - docs/*

This watches everything except branches under experimental/ and docs/. The exclude list takes precedence over the include list, which is the correct behavior for a deny-list approach.

Path Filters

Path filters let you scope triggers to specific directories. This is critical for monorepos where a single repository contains multiple services:

trigger:
  branches:
    include:
      - main
  paths:
    include:
      - src/api/**
      - src/shared/**
    exclude:
      - src/api/docs/**
      - '**/*.md'

With this configuration, the pipeline only runs when files under src/api/ or src/shared/ change, and it skips documentation changes even within those directories. Path filters evaluate against the diff between the push and the previous commit on that branch.

One thing that catches people: path filters use forward slashes regardless of your OS. Even if you develop on Windows, use src/api/**, not src\api\**.

Batch Mode

By default, Azure DevOps queues a separate build for every push. If your team pushes frequently, you can end up with a backlog of redundant builds. Batch mode collapses multiple pushes into a single build:

trigger:
  batch: true
  branches:
    include:
      - main
      - develop

When batch: true is set and a build is already running, Azure DevOps waits until that build finishes and then starts a single new build that includes all commits pushed during the previous run. This is a significant cost saver for busy repositories. On a team I worked with that averaged 40 pushes per hour to develop, enabling batch mode cut our CI build count from 40 per hour to roughly 8.

The tradeoff is reduced granularity. If builds A through E are batched into one run and it fails, you need to investigate which of those five commits introduced the issue. For most teams, this is an acceptable tradeoff on develop but not on main where you want per-commit traceability.

Tag Triggers

Tags are a distinct trigger mechanism from branches. You can trigger pipelines when tags matching a pattern are pushed:

trigger:
  branches:
    include:
      - main
  tags:
    include:
      - v*
      - release-*
    exclude:
      - v*-beta

This fires the pipeline when a tag like v1.2.3 or release-2026.01 is pushed, but ignores beta tags like v1.2.3-beta. Tag triggers are the backbone of release workflows. Push a semver tag, and your pipeline publishes to npm, builds a Docker image, or deploys to production.

A practical Node.js build script that reads the tag version:

// scripts/read-version.js
var execSync = require('child_process').execSync;

var tag = process.env.BUILD_SOURCEBRANCH || '';
var version = tag.replace('refs/tags/v', '').replace('refs/tags/', '');

if (!version || version === tag) {
  var pkg = require('../package.json');
  version = pkg.version + '-dev.' + Date.now();
  console.log('No tag detected, using dev version: ' + version);
} else {
  console.log('Tag version detected: ' + version);
}

// Write version to a file for downstream steps
var fs = require('fs');
fs.writeFileSync('.version', version, 'utf8');
console.log('Version written to .version file');
steps:
  - task: NodeTool@0
    inputs:
      versionSpec: '20.x'
  - script: node scripts/read-version.js
    displayName: 'Determine build version'
  - script: |
      VERSION=$(cat .version)
      echo "##vso[task.setvariable variable=PackageVersion]$VERSION"
    displayName: 'Set pipeline variable'

PR Triggers

PR triggers (called pr in YAML) run validation builds when a pull request is created or updated. This is distinct from CI triggers: PR triggers validate proposed changes before they are merged.

Basic PR Configuration

pr:
  branches:
    include:
      - main
      - release/*
  paths:
    include:
      - src/**
    exclude:
      - '**/*.md'
      - docs/**

This runs validation when a PR targets main or any release/* branch, but only if the PR modifies source code, not documentation.

Draft PR Handling

By default, Azure DevOps runs PR validation on draft pull requests. If your team uses drafts for work-in-progress, this wastes compute:

pr:
  branches:
    include:
      - main
  drafts: false

Setting drafts: false skips validation for draft PRs. The validation kicks in when the PR is marked as ready for review. I recommend this setting for any team where draft PRs are common. There is no reason to validate code the author has explicitly flagged as incomplete.

PR vs. CI: The Critical Distinction

A common mistake is confusing PR triggers with CI triggers. They serve different purposes:

Aspect CI Trigger PR Trigger
Fires when Code is pushed to a branch A PR is created/updated targeting a branch
Build source The pushed branch Merge commit of source into target
Typical use Build and deploy Validate before merge
Build.Reason IndividualCI or BatchedCI PullRequest

The PR build actually compiles a temporary merge commit. Azure DevOps merges the source branch into the target branch locally and builds that result. This means PR validation catches merge conflicts that a CI build on the source branch alone would miss.

Azure Repos vs. GitHub PR Triggers

If your repository is hosted in GitHub rather than Azure Repos, YAML PR triggers behave differently. For GitHub repos, the pr keyword in YAML is respected directly. For Azure Repos, the YAML pr section defines the default, but it can be overridden by branch policies in the Azure DevOps UI.

In practice, most teams using Azure Repos configure PR validation through branch policies (Project Settings > Repos > Branch Policies > Build Validation) rather than the YAML pr section. Branch policies give you additional controls like requiring a minimum number of reviewers and linking work items.

If you have both a YAML pr trigger and a branch policy build validation for the same pipeline, you will get duplicate builds. The fix is to disable the YAML PR trigger and rely solely on the branch policy:

pr: none

Scheduled Triggers

Scheduled triggers run your pipeline on a cron schedule, regardless of whether any code has changed. They are essential for nightly builds, weekly security scans, and periodic integration tests against external dependencies.

Cron Syntax

Azure DevOps uses standard five-field cron syntax:

┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12)
│ │ │ │ ┌───────────── day of week (0-6, Sunday=0)
│ │ │ │ │
│ │ │ │ │
* * * * *

All times are in UTC. This is not configurable, and it trips up every team that forgets to account for their local timezone offset.

Basic Scheduled Build

schedules:
  - cron: '0 2 * * 1-5'
    displayName: 'Weeknight build at 2 AM UTC'
    branches:
      include:
        - main
    always: false

This runs at 2:00 AM UTC, Monday through Friday, against the main branch. The always: false setting means the build only runs if there have been code changes since the last scheduled run. If nothing changed, the build is skipped.

Always vs. Changes-Only

The always flag controls whether the scheduled build runs even when no code has changed:

schedules:
  - cron: '0 6 * * 0'
    displayName: 'Weekly dependency audit'
    branches:
      include:
        - main
    always: true

  - cron: '0 2 * * 1-5'
    displayName: 'Nightly integration tests'
    branches:
      include:
        - main
    always: false

Use always: true for:

  • Security scans and dependency audits (vulnerabilities can appear without code changes)
  • Integration tests against external APIs or databases
  • Compliance reporting

Use always: false for:

  • Standard build and test runs (no point rebuilding identical code)
  • Artifact publishing (avoid duplicate artifacts)

Multiple Schedules

You can define multiple schedules with different branch targets:

schedules:
  - cron: '0 2 * * 1-5'
    displayName: 'Nightly on main'
    branches:
      include:
        - main
    always: false

  - cron: '0 4 * * 1-5'
    displayName: 'Nightly on release branches'
    branches:
      include:
        - release/*
    always: false

  - cron: '0 8 * * 6'
    displayName: 'Saturday full regression'
    branches:
      include:
        - main
    always: true

Each schedule operates independently. A pipeline can have up to 10 scheduled triggers.


Pipeline Resource Triggers

Pipeline triggers let you chain pipelines together. When Pipeline A completes, Pipeline B automatically runs. This is how you implement cross-pipeline dependencies without coupling their YAML files.

Basic Pipeline Trigger

resources:
  pipelines:
    - pipeline: buildApi
      source: 'API-Build-Pipeline'
      trigger:
        branches:
          include:
            - main

trigger: none

steps:
  - download: buildApi
    artifact: api-package
  - script: |
      echo "Deploying artifact from pipeline run $(resources.pipeline.buildApi.runID)"
    displayName: 'Deploy API'

The source value must match the name of the pipeline as it appears in Azure DevOps (the pipeline name, not the YAML file name). The pipeline alias (buildApi) is what you use to reference the upstream pipeline's artifacts and metadata within this pipeline.

Setting trigger: none on the CI trigger is important here. Without it, this pipeline would also fire on code pushes, which is not what you want for a deployment pipeline that should only run after the build pipeline succeeds.

Filtering on Stages and Tags

You can filter pipeline triggers to specific stages or tags from the upstream pipeline:

resources:
  pipelines:
    - pipeline: buildApi
      source: 'API-Build-Pipeline'
      trigger:
        branches:
          include:
            - main
        stages:
          - PublishArtifacts
        tags:
          - production-ready

This trigger only fires if the upstream pipeline completed the PublishArtifacts stage and the run was tagged with production-ready. Stage filtering is useful when the upstream pipeline has multiple stages and you only care about successful completion of a specific one.


Disabling Triggers

Sometimes you need to disable triggers entirely. This is common for pipelines that should only run manually or through pipeline resource triggers.

Disabling CI Triggers

trigger: none

Disabling PR Triggers

pr: none

Disabling Both

trigger: none
pr: none

A pipeline with both triggers disabled can still be run manually from the Azure DevOps UI, via the REST API, or through a pipeline resource trigger. This is the correct configuration for deployment pipelines, one-off migration scripts, and infrastructure provisioning pipelines that you want human-initiated only.


Wildcards and Exclusion Patterns

Azure DevOps trigger filters support wildcards, and understanding the pattern syntax prevents frustrating misconfigurations.

Supported Patterns

Pattern Matches Example
* Any single path segment release/* matches release/1.0 but not release/1.0/hotfix
** Zero or more path segments src/** matches src/foo, src/foo/bar/baz.js
Exact Literal match main matches only main

Branch Pattern Examples

trigger:
  branches:
    include:
      - main
      - develop
      - release/*
      - feature/*
    exclude:
      - feature/experimental-*
      - feature/wip-*

The single * wildcard in release/* matches release/1.0 but not release/1.0/hotfix. If your release branches use deeper nesting, you would need release/**.

Path Pattern Examples

trigger:
  paths:
    include:
      - 'packages/api/**'
      - 'packages/shared/**'
    exclude:
      - '**/*.test.js'
      - '**/*.spec.js'
      - '**/README.md'
      - '**/__tests__/**'

This triggers on changes to the API and shared packages but excludes test files. The ** ensures the exclusion applies at any directory depth.


Trigger Combinations and Precedence Rules

When you combine multiple trigger types, the precedence rules matter:

  1. CI and PR triggers are independent. A push to a branch can fire the CI trigger, and a PR update can fire the PR trigger. They are separate evaluation paths.

  2. Scheduled triggers run on their own schedule. They do not interact with CI or PR triggers. A scheduled run happens regardless of whether a CI build already ran for the same commit.

  3. Pipeline resource triggers are additive. If you have both a CI trigger and a pipeline resource trigger, both can independently start the pipeline.

  4. trigger: none only disables CI triggers. It does not affect PR triggers, scheduled triggers, or pipeline resource triggers.

  5. YAML triggers vs. UI overrides. If you override triggers in the Azure DevOps UI (pipeline settings > Triggers tab), the UI settings take precedence over the YAML file for CI and scheduled triggers. This is a common source of confusion. The fix is to check "Override the YAML CI trigger" and "Override the YAML scheduled trigger" checkboxes in the UI, or better yet, avoid using the UI overrides entirely and keep everything in YAML.

A typical production pipeline combines multiple trigger types:

trigger:
  batch: true
  branches:
    include:
      - main
      - develop
  paths:
    include:
      - src/**
    exclude:
      - '**/*.md'

pr:
  branches:
    include:
      - main
  paths:
    include:
      - src/**
  drafts: false

schedules:
  - cron: '0 3 * * 1-5'
    displayName: 'Nightly security scan'
    branches:
      include:
        - main
    always: true

Trigger Debugging: Why Didn't My Pipeline Run?

This is the most common question teams ask about triggers. The pipeline should have run but did not. Here is a systematic debugging approach.

Step 1: Check Build.Reason

If the pipeline did run but you are unsure why, check the Build.Reason variable in the logs. It tells you which trigger type started the build:

Value Trigger Type
IndividualCI CI trigger (single push)
BatchedCI CI trigger (batched)
PullRequest PR validation trigger
Schedule Scheduled trigger
ResourceTrigger Pipeline resource trigger
Manual Manually started

Step 2: Verify Branch Matching

If the pipeline did not run, the most common cause is a branch filter mismatch. Check whether your branch name matches the include pattern and is not caught by an exclude pattern.

# Check what branch name Azure DevOps sees
git rev-parse --abbrev-ref HEAD
# Output: feature/AUTH-123-login-flow

# Does this match your trigger?
# trigger.branches.include: ['feature/*'] -> Yes, matches
# trigger.branches.include: ['main', 'develop'] -> No, does not match

Step 3: Verify Path Matching

If you have path filters, verify that your commit actually modifies files in the included paths:

# List files changed in the last commit
git diff --name-only HEAD~1 HEAD
# Output:
# docs/README.md
# src/api/package.json

# If your path filter only includes 'src/api/**', this would match
# If your path filter excludes '**/*.md', docs/README.md is excluded
# but src/api/package.json still matches, so the trigger fires

If every changed file matches an exclude pattern or none match an include pattern, the pipeline will not run. Azure DevOps evaluates path filters against the full diff of the push, not individual commits within the push.

Step 4: Check for UI Overrides

Navigate to Pipelines > your pipeline > Edit > Triggers (the three-dot menu). If "Override the YAML continuous integration trigger" is checked, the UI settings win and your YAML trigger section is ignored. I have seen this cause hours of debugging when someone checked that box months ago and the team forgot.

Step 5: Check Service Connection Permissions

For pipeline resource triggers, the downstream pipeline needs access to the upstream pipeline. If they are in different projects, verify that the service connection and project visibility settings allow cross-project access.

Writing a Trigger Diagnostic Script

For teams that frequently debug trigger issues, a small diagnostic script in your pipeline can log exactly what happened:

// scripts/trigger-diagnostic.js
var reason = process.env.BUILD_REASON || 'unknown';
var branch = process.env.BUILD_SOURCEBRANCH || 'unknown';
var branchName = process.env.BUILD_SOURCEBRANCHNAME || 'unknown';
var prId = process.env.SYSTEM_PULLREQUEST_PULLREQUESTID || 'N/A';
var prSource = process.env.SYSTEM_PULLREQUEST_SOURCEBRANCH || 'N/A';
var prTarget = process.env.SYSTEM_PULLREQUEST_TARGETBRANCH || 'N/A';
var pipelineResource = process.env.RESOURCES_PIPELINE_SOURCE || 'N/A';
var cronSchedule = process.env.BUILD_CRONSCHEDULE_DISPLAYNAME || 'N/A';
var buildId = process.env.BUILD_BUILDID || 'unknown';

console.log('=== Trigger Diagnostic ===');
console.log('Build ID:         ' + buildId);
console.log('Build Reason:     ' + reason);
console.log('Source Branch:    ' + branch);
console.log('Branch Name:      ' + branchName);
console.log('');

if (reason === 'PullRequest') {
  console.log('PR ID:            ' + prId);
  console.log('PR Source Branch: ' + prSource);
  console.log('PR Target Branch: ' + prTarget);
} else if (reason === 'Schedule') {
  console.log('Schedule:         ' + cronSchedule);
} else if (reason === 'ResourceTrigger') {
  console.log('Source Pipeline:  ' + pipelineResource);
}

console.log('==========================');
steps:
  - script: node scripts/trigger-diagnostic.js
    displayName: 'Log trigger information'
    condition: always()

Running this as the first step with condition: always() ensures you always have trigger context in your build logs, regardless of whether subsequent steps fail.


Trigger Best Practices for Monorepos

Monorepos present the biggest trigger configuration challenge. Without path filters, every commit triggers every pipeline, even if the change is in an unrelated service.

The Service-Per-Pipeline Pattern

Create a separate pipeline for each service, each with its own path filters:

# azure-pipelines-api.yml
trigger:
  branches:
    include:
      - main
  paths:
    include:
      - packages/api/**
      - packages/shared/**

# azure-pipelines-web.yml
trigger:
  branches:
    include:
      - main
  paths:
    include:
      - packages/web/**
      - packages/shared/**

# azure-pipelines-worker.yml
trigger:
  branches:
    include:
      - main
  paths:
    include:
      - packages/worker/**
      - packages/shared/**

Notice that each service pipeline includes packages/shared/**. Changes to shared code trigger rebuilds of all dependent services, which is correct behavior.

Shared Dependencies and Path Filters

A common mistake is forgetting to include shared paths. If your API depends on packages/shared/, and you only include packages/api/** in the path filter, changes to shared code will not trigger the API pipeline. Your main branch will have untested changes. I have seen this cause production incidents where a shared utility function was updated but the consuming service was never rebuilt or tested against the change.

Build a dependency map and encode it in your path filters:

// scripts/check-dependencies.js
var fs = require('fs');
var path = require('path');

var workspaceRoot = path.resolve(__dirname, '..');
var packagesDir = path.join(workspaceRoot, 'packages');

function getWorkspaceDependencies(packageName) {
  var pkgPath = path.join(packagesDir, packageName, 'package.json');
  if (!fs.existsSync(pkgPath)) {
    console.error('Package not found: ' + packageName);
    return [];
  }

  var pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
  var allDeps = Object.assign({}, pkg.dependencies || {}, pkg.devDependencies || {});
  var workspaceDeps = [];

  Object.keys(allDeps).forEach(function(dep) {
    var depVersion = allDeps[dep];
    if (depVersion.indexOf('workspace:') === 0 || depVersion.indexOf('file:') === 0) {
      var depName = dep.replace('@myorg/', '');
      workspaceDeps.push(depName);
    }
  });

  return workspaceDeps;
}

var serviceName = process.argv[2];
if (!serviceName) {
  console.error('Usage: node check-dependencies.js <package-name>');
  process.exit(1);
}

var deps = getWorkspaceDependencies(serviceName);
console.log('Workspace dependencies for ' + serviceName + ':');
deps.forEach(function(dep) {
  console.log('  - packages/' + dep + '/**');
});
console.log('');
console.log('Include these paths in your pipeline trigger.');
node scripts/check-dependencies.js api
# Output:
# Workspace dependencies for api:
#   - packages/shared/**
#   - packages/config/**
#
# Include these paths in your pipeline trigger.

Complete Working Example

Here is a comprehensive azure-pipelines.yml for a Node.js monorepo with three services (API, Web, Worker) plus shared packages. It demonstrates CI triggers for feature branches, PR validation, nightly scheduled builds, and release tag triggers.

# azure-pipelines.yml
# Monorepo pipeline for a Node.js project with API, Web, and Worker services

trigger:
  batch: true
  branches:
    include:
      - main
      - develop
      - release/*
      - feature/*
    exclude:
      - feature/experimental-*
      - feature/wip-*
  paths:
    include:
      - packages/api/**
      - packages/shared/**
      - packages/config/**
      - package.json
      - package-lock.json
    exclude:
      - '**/*.md'
      - '**/*.txt'
      - '**/docs/**'
  tags:
    include:
      - v*
    exclude:
      - v*-alpha
      - v*-beta

pr:
  branches:
    include:
      - main
      - release/*
  paths:
    include:
      - packages/api/**
      - packages/shared/**
      - packages/config/**
    exclude:
      - '**/*.md'
  drafts: false

schedules:
  - cron: '0 3 * * 1-5'
    displayName: 'Weeknight regression (3 AM UTC)'
    branches:
      include:
        - main
    always: false

  - cron: '0 6 * * 0'
    displayName: 'Weekly dependency audit (Sunday 6 AM UTC)'
    branches:
      include:
        - main
    always: true

variables:
  nodeVersion: '20.x'
  npmCacheFolder: $(Pipeline.Workspace)/.npm
  isMain: ${{ eq(variables['Build.SourceBranch'], 'refs/heads/main') }}
  isTag: ${{ startsWith(variables['Build.SourceBranch'], 'refs/tags/v') }}
  isScheduled: ${{ eq(variables['Build.Reason'], 'Schedule') }}

pool:
  vmImage: 'ubuntu-latest'

stages:
  - stage: Diagnostics
    displayName: 'Build Diagnostics'
    jobs:
      - job: TriggerInfo
        displayName: 'Log Trigger Information'
        steps:
          - checkout: none
          - script: |
              echo "Build Reason: $(Build.Reason)"
              echo "Source Branch: $(Build.SourceBranch)"
              echo "Source Branch Name: $(Build.SourceBranchName)"
              echo "Build ID: $(Build.BuildId)"
              echo "Is Main: $(isMain)"
              echo "Is Tag: $(isTag)"
              echo "Is Scheduled: $(isScheduled)"
            displayName: 'Print trigger context'

  - stage: Build
    displayName: 'Build and Test'
    dependsOn: Diagnostics
    jobs:
      - job: BuildAndTest
        displayName: 'Install, Build, Test'
        steps:
          - task: NodeTool@0
            displayName: 'Use Node.js $(nodeVersion)'
            inputs:
              versionSpec: $(nodeVersion)

          - task: Cache@2
            displayName: 'Cache npm packages'
            inputs:
              key: 'npm | "$(Agent.OS)" | package-lock.json'
              restoreKeys: |
                npm | "$(Agent.OS)"
              path: $(npmCacheFolder)

          - script: npm ci --cache $(npmCacheFolder)
            displayName: 'Install dependencies'

          - script: npm run build --workspace=packages/shared
            displayName: 'Build shared package'

          - script: npm run build --workspace=packages/api
            displayName: 'Build API'

          - script: npm run lint
            displayName: 'Run linter'

          - script: npm test -- --ci --coverage
            displayName: 'Run tests with coverage'

          - task: PublishTestResults@2
            displayName: 'Publish test results'
            inputs:
              testResultsFormat: 'JUnit'
              testResultsFiles: '**/junit.xml'
              mergeTestResults: true
            condition: succeededOrFailed()

          - task: PublishCodeCoverageResults@2
            displayName: 'Publish code coverage'
            inputs:
              summaryFileLocation: '**/coverage/cobertura-coverage.xml'
            condition: succeededOrFailed()

  - stage: SecurityAudit
    displayName: 'Security Audit'
    dependsOn: Build
    condition: |
      or(
        eq(variables['Build.Reason'], 'Schedule'),
        eq(variables['Build.SourceBranch'], 'refs/heads/main')
      )
    jobs:
      - job: AuditDependencies
        displayName: 'npm audit'
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: $(nodeVersion)
          - script: npm ci
            displayName: 'Install dependencies'
          - script: |
              npm audit --production --audit-level=high 2>&1 | tee audit-results.txt
              AUDIT_EXIT=$?
              if [ $AUDIT_EXIT -ne 0 ]; then
                echo "##vso[task.logissue type=warning]Security vulnerabilities detected. Review audit-results.txt."
              fi
            displayName: 'Run security audit'
          - publish: audit-results.txt
            artifact: security-audit
            displayName: 'Publish audit results'
            condition: succeededOrFailed()

  - stage: Package
    displayName: 'Package Artifacts'
    dependsOn: Build
    condition: |
      and(
        succeeded(),
        or(
          eq(variables['Build.SourceBranch'], 'refs/heads/main'),
          startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
        )
      )
    jobs:
      - job: PackageApi
        displayName: 'Package API'
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: $(nodeVersion)
          - script: npm ci --production
            displayName: 'Install production dependencies'
          - script: node scripts/read-version.js
            displayName: 'Determine version'
          - script: |
              VERSION=$(cat .version)
              echo "##vso[task.setvariable variable=PackageVersion]$VERSION"
              echo "Packaging version: $VERSION"
            displayName: 'Set version variable'
          - task: ArchiveFiles@2
            displayName: 'Create deployment package'
            inputs:
              rootFolderOrFile: 'packages/api'
              includeRootFolder: false
              archiveType: 'tar'
              tarCompression: 'gz'
              archiveFile: '$(Build.ArtifactStagingDirectory)/api-$(PackageVersion).tar.gz'
          - publish: $(Build.ArtifactStagingDirectory)
            artifact: api-package
            displayName: 'Publish API artifact'

  - stage: Deploy
    displayName: 'Deploy to Staging'
    dependsOn: Package
    condition: |
      and(
        succeeded(),
        eq(variables['Build.SourceBranch'], 'refs/heads/main'),
        ne(variables['Build.Reason'], 'PullRequest')
      )
    jobs:
      - deployment: DeployStaging
        displayName: 'Deploy to Staging'
        environment: 'staging'
        strategy:
          runOnce:
            deploy:
              steps:
                - download: current
                  artifact: api-package
                - script: |
                    echo "Deploying to staging environment..."
                    ls -la $(Pipeline.Workspace)/api-package/
                  displayName: 'Deploy API to staging'

  - stage: ReleasePublish
    displayName: 'Publish Release'
    dependsOn: Package
    condition: |
      and(
        succeeded(),
        startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
      )
    jobs:
      - job: PublishNpm
        displayName: 'Publish to npm'
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: $(nodeVersion)
          - download: current
            artifact: api-package
          - script: |
              VERSION=$(cat .version)
              echo "Publishing version $VERSION to npm registry..."
              # npm publish --tag latest
              echo "Published successfully"
            displayName: 'Publish to npm'

This pipeline covers all four trigger types:

  • CI triggers fire on pushes to main, develop, release/*, and feature/* branches when API-related files change, with batch mode enabled to reduce build volume.
  • PR triggers validate changes targeting main or release/*, skipping draft PRs and documentation-only changes.
  • Scheduled triggers run nightly regression tests on weeknights and a weekly security audit on Sundays.
  • Tag triggers fire on v* tags (excluding alpha and beta), which flow through to the Package and ReleasePublish stages.

Common Issues and Troubleshooting

Issue 1: Pipeline Never Triggers on Push

Symptom: You push to main but the pipeline does not run. No build appears in the pipeline history.

Typical cause: UI trigger override is enabled.

Pipeline > Edit > ... (three-dot menu) > Triggers
  > "Override the YAML continuous integration trigger" is CHECKED
  > Branch filters in the UI are set to a different branch

Fix: Uncheck the UI override or update the UI branch filters to match your YAML. Better yet, delete the UI overrides entirely and manage everything in YAML.

Issue 2: Duplicate Builds on PR

Symptom: Every pull request triggers two builds: one with Build.Reason = PullRequest and another with Build.Reason = IndividualCI.

Typical cause: You have both a YAML pr trigger and a branch policy build validation configured for the same pipeline.

Project Settings > Repos > Policies > Branch Policies > main
  > Build Validation > Your pipeline is listed here

Fix: Remove the YAML pr trigger by adding pr: none and rely on the branch policy, or remove the branch policy and rely on the YAML trigger. Do not use both.

Issue 3: Scheduled Build Runs at Wrong Time

Symptom: Your cron is set to 0 9 * * 1-5 expecting 9 AM local time, but the build runs at 9 AM UTC, which is 1 AM PST or 4 AM EST.

Error log:

Build started at 2026-01-15T09:00:03Z
Scheduled trigger: 'Weekday morning build (9 AM)'

Fix: Convert your desired local time to UTC. For 9 AM Eastern (UTC-5), use 0 14 * * 1-5. For 9 AM Pacific (UTC-8), use 0 17 * * 1-5. Remember that daylight saving time shifts the offset by one hour, so your "9 AM local" schedule will drift by an hour twice a year.

Issue 4: Path Filters Ignore Changes in Submodules

Symptom: You update a Git submodule and push, but the pipeline does not trigger even though the submodule path is in your include list.

Typical cause: Azure DevOps path filters evaluate against the files changed in the push, and a submodule update only changes the submodule pointer file (a single hash reference), not the files inside the submodule.

git diff --name-only HEAD~1 HEAD
# Output:
# packages/external-lib   (this is the submodule pointer, a single line)

Fix: Your path filter needs to include the submodule pointer path itself (e.g., packages/external-lib), not the files inside the submodule. Alternatively, add a script step that checks out the submodule and runs tests explicitly.

Issue 5: Tag Trigger Does Not Fire

Symptom: You push tag v2.1.0 but the pipeline does not run.

Typical cause: You pushed the tag without pushing the commit it points to, or the tag trigger is excluded by a pattern.

# Wrong: push only the tag
git tag v2.1.0
git push origin v2.1.0

# Right: push the commit AND the tag
git push origin main
git tag v2.1.0
git push origin v2.1.0

Also verify the tag name does not match an exclude pattern. If your YAML has exclude: [v*-beta] and you pushed v2.1.0-beta.1, it would match the exclude pattern even though you might not expect it to, because v*-beta uses a wildcard that does not require an exact suffix match in all cases. Be explicit: exclude: [v*-beta*] to catch all beta variants.


Best Practices

  • Keep all trigger configuration in YAML, not the UI. UI overrides are invisible to code review and create drift between what the YAML says and what actually happens. The only exception is branch policy build validation for Azure Repos, which must be configured in the UI.

  • Always set pr: none when using branch policy build validation. This prevents duplicate PR builds and keeps your trigger configuration in one place (the branch policy settings).

  • Use batch: true on high-traffic branches. For branches that receive more than a few pushes per hour, batching prevents a queue of redundant builds. Do not enable batching on main if you need per-commit auditability for compliance.

  • Include shared dependency paths in every service pipeline. In monorepos, forgetting to include packages/shared/** in a service's path filter means changes to shared code bypass that service's CI. Map your workspace dependencies and encode them in path filters.

  • Set drafts: false on PR triggers. There is no value in running full CI validation on work-in-progress PRs. Save the compute for when the author signals the code is ready for review.

  • Use always: true only for scheduled builds that check external state. Security audits, dependency scans, and integration tests against external services should run on schedule regardless of code changes. Standard build-and-test pipelines should use always: false to avoid wasting resources.

  • Add a trigger diagnostic step to every pipeline. A simple script that logs Build.Reason, Build.SourceBranch, and PR metadata makes debugging trigger issues trivial. Run it with condition: always() so it executes even when other steps fail.

  • Test trigger changes in a branch pipeline first. Before modifying triggers on your main pipeline, create a test pipeline that points to a branch, configure the triggers, and verify they fire correctly. This prevents the all-too-common scenario of pushing a trigger change to main and discovering it broke CI for the entire team.

  • Document your cron schedules in UTC with local time equivalents. Future maintainers (including future you) will not remember the UTC offset. Add a comment like # 3 AM UTC = 7 PM PST / 10 PM EST next to every cron expression.

  • Separate build pipelines from deployment pipelines. Use pipeline resource triggers to chain them. Build pipelines should have CI and PR triggers. Deployment pipelines should have trigger: none and pr: none, running only via pipeline resource triggers or manual invocation. This separation of concerns makes trigger configuration cleaner and prevents accidental deployments.


References

Powered by Contentful