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:
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.
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.
Pipeline resource triggers are additive. If you have both a CI trigger and a pipeline resource trigger, both can independently start the pipeline.
trigger: noneonly disables CI triggers. It does not affect PR triggers, scheduled triggers, or pipeline resource triggers.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/*, andfeature/*branches when API-related files change, with batch mode enabled to reduce build volume. - PR triggers validate changes targeting
mainorrelease/*, 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: nonewhen using branch policy build validation. This prevents duplicate PR builds and keeps your trigger configuration in one place (the branch policy settings).Use
batch: trueon 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 onmainif 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: falseon 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: trueonly 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 usealways: falseto 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 withcondition: 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
mainand 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 ESTnext 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: noneandpr: none, running only via pipeline resource triggers or manual invocation. This separation of concerns makes trigger configuration cleaner and prevents accidental deployments.
References
- Azure DevOps YAML Pipeline Trigger Reference - Official Microsoft documentation for CI trigger syntax
- PR Trigger Documentation - Pull request trigger configuration details
- Scheduled Triggers - Cron syntax and scheduling options
- Pipeline Resources and Triggers - Cross-pipeline dependency triggers
- Build Variables Reference - Predefined variables including Build.Reason and source branch info
- Wildcards in Trigger Patterns - Pattern matching syntax for branch and path filters
