Back to Software Engineering & Delivery Mastery Series Azure DevOps Bootcamp

Module 4: Azure Pipelines — YAML Fundamentals

June 3, 2026 Wasil Zafar 45 min read

Master Azure Pipelines YAML from first principles — pipeline structure, trigger types, variables and variable groups, parameters, expressions, runtime conditions, resources, and building your first complete CI pipeline.

Table of Contents

  1. Pipeline Basics
  2. Variables & Parameters
  3. Logic & Control
  4. Practice

Introduction to Azure Pipelines

Azure Pipelines is the CI/CD automation engine within Azure DevOps. It takes your code, builds it, tests it, and deploys it — automatically, reliably, and repeatably. Every time a developer pushes code, Pipelines can validate it within minutes, catching bugs before they reach production.

Think of a YAML pipeline as a recipe — triggers are "when to cook" (on push? on schedule? when another pipeline finishes?), stages are the courses (appetizer → main → dessert), jobs are individual dishes within each course, and steps are the cooking instructions for each dish. Variables are your ingredient measurements — you define them once and reference them throughout the recipe.

Classic vs YAML Pipelines

Azure Pipelines offers two authoring experiences:

Aspect YAML Pipelines (Recommended) Classic Pipelines (Legacy)
Definition Code in azure-pipelines.yml file GUI-based designer in browser
Version control Lives in your repo — full Git history Stored in Azure DevOps service
Code review Changes go through PRs like any code No PR workflow — direct edits
Reproducibility Branch-specific — each branch has its own pipeline version Single definition for all branches
Templates Powerful reuse via YAML templates Task groups (limited reuse)
Future All new features ship here Maintenance mode — no new features
Use YAML for all new projects. Classic pipelines still work and are supported, but Microsoft invests exclusively in YAML pipelines. YAML gives you version control, PR-based reviews, branch-specific definitions, and powerful template reuse — all things that Classic cannot match.

For CI/CD concepts and theory, see Part 14: Continuous Integration and Part 15: CI/CD Pipeline Architecture. This module focuses on the YAML syntax — the hands-on "how" of writing pipelines.

Pipeline Structure (The Big Picture)

Every YAML pipeline follows a nested hierarchy: Pipeline → Stages → Jobs → Steps. Understanding this hierarchy is the single most important concept for writing pipelines.

Pipeline Hierarchy
flowchart TD
    A[Pipeline] --> B[Stage: Build]
    A --> C[Stage: Test]
    A --> D[Stage: Deploy]
    B --> E[Job: compile]
    B --> F[Job: lint]
    C --> G[Job: unit-tests]
    C --> H[Job: integration-tests]
    D --> I[Job: deploy-staging]
    D --> J[Job: deploy-prod]
    E --> K[Step: checkout]
    E --> L[Step: dotnet build]
    E --> M[Step: publish artifact]
                            
Element Purpose Required? Contains
Pipeline Top-level definition — triggers, variables, stages Yes (the file itself) Stages (or Jobs directly)
Stage Logical division (Build, Test, Deploy) No — implicit if omitted Jobs
Job Unit of work that runs on one agent No — implicit if omitted Steps
Step Single action (run script, use task) Yes — you need at least one

The simplest possible pipeline skips stages and jobs entirely — Azure Pipelines creates implicit ones for you:

# The simplest possible pipeline — no explicit stages or jobs.
# Azure Pipelines wraps your steps in an implicit job and stage.
trigger:
  - main                    # Run when code is pushed to 'main' branch

pool:
  vmImage: 'ubuntu-latest'  # Use a Microsoft-hosted Ubuntu agent

steps:
  - script: echo "Hello, Azure Pipelines!"  # A single shell command
    displayName: 'Run a one-line script'     # Friendly name in the UI

When you need multiple stages (e.g., Build → Test → Deploy with approval gates between them), you spell out the full hierarchy:

# Full hierarchy — Pipeline with explicit Stages, Jobs, and Steps.
# Use this structure when you need approval gates between stages,
# different agent pools per job, or parallel jobs within a stage.
trigger:
  - main

stages:
  # ─── Stage 1: Build ───────────────────────────────────────
  - stage: Build
    displayName: 'Build & Package'
    jobs:
      - job: CompileApp
        displayName: 'Compile Application'
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - checkout: self           # Clone the repository
          - script: dotnet build     # Compile the code
            displayName: 'Build solution'

  # ─── Stage 2: Test ────────────────────────────────────────
  - stage: Test
    displayName: 'Run Tests'
    dependsOn: Build               # Only runs after Build succeeds
    jobs:
      - job: UnitTests
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - script: dotnet test
            displayName: 'Run unit tests'

  # ─── Stage 3: Deploy ──────────────────────────────────────
  - stage: Deploy
    displayName: 'Deploy to Production'
    dependsOn: Test                # Only runs after Test succeeds
    jobs:
      - job: DeployApp
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - script: echo "Deploying..."
            displayName: 'Deploy application'

Triggers & Scheduling

Triggers tell Azure Pipelines when to run your pipeline. Without triggers, your pipeline would never execute. Azure Pipelines supports five trigger types:

CI Triggers (Push)

The most common trigger — run the pipeline whenever code is pushed to specific branches:

# CI trigger — runs on push to these branches.
# Branch filters control WHICH pushes trigger the pipeline.
trigger:
  branches:
    include:
      - main               # Production branch
      - develop            # Integration branch
      - release/*          # All release branches (wildcard)
    exclude:
      - feature/experimental*  # Skip experimental features

  paths:
    include:
      - src/**             # Only trigger when source code changes
      - tests/**           # Or when tests change
    exclude:
      - docs/**            # Don't trigger for documentation-only changes
      - '*.md'             # Don't trigger for README updates

  batch: true              # If pushes come faster than builds finish,
                           # batch them into a single run (prevents queue flood)

PR Triggers (Pull Request Validation)

Run the pipeline when a pull request targets specific branches — this is your quality gate before merge:

# PR trigger — validates pull requests before they merge.
# This is SEPARATE from the CI trigger above.
pr:
  branches:
    include:
      - main               # Validate PRs targeting main
      - release/*          # Validate PRs targeting release branches
    exclude:
      - feature/draft-*    # Skip draft feature branches

  paths:
    include:
      - src/**             # Only validate when source changes
    exclude:
      - docs/**            # Skip docs-only PRs

  drafts: false            # Don't run on draft PRs (save agent time)
Unlike GitHub Actions which uses on:, Azure Pipelines uses trigger: for CI (push) and pr: for PR validation — they're separate configurations. A pipeline can have both, one, or neither (manual-only).

Scheduled Triggers (Cron)

Run the pipeline on a schedule — useful for nightly builds, weekly security scans, or periodic cleanup:

# Scheduled triggers — cron syntax (minute hour day-of-month month day-of-week).
# All times are UTC unless you specify a timezone.
schedules:
  - cron: '0 2 * * 1-5'          # Weekdays at 2:00 AM UTC
    displayName: 'Nightly build'
    branches:
      include:
        - main
    always: false                  # Only run if there are new changes
                                   # (set to true to run even without changes)

  - cron: '0 8 * * 0'            # Sundays at 8:00 AM UTC
    displayName: 'Weekly security scan'
    branches:
      include:
        - main
    always: true                   # Always run (security scans should be regular)

Pipeline Triggers (Chaining)

Trigger one pipeline when another completes — perfect for separating build from deploy or chaining microservice builds:

# Pipeline resource trigger — run THIS pipeline when ANOTHER finishes.
# Common pattern: separate build pipeline feeds into deploy pipeline.
resources:
  pipelines:
    - pipeline: buildPipeline           # Internal alias (your choice)
      source: 'MyApp-CI'                # Name of the triggering pipeline
      trigger:
        branches:
          include:
            - main                       # Only trigger from main branch builds
        stages:
          - Build                        # Only trigger when Build stage completes

Disabling Triggers

# Disable all automatic triggers — pipeline runs only manually.
# Useful for deployment pipelines that need human approval to start.
trigger: none
pr: none

Variables & Variable Groups

Variables store values you use throughout your pipeline — connection strings, version numbers, feature flags, file paths. Azure Pipelines offers multiple ways to define and consume variables, each with different scoping and security characteristics.

Inline Variables

The simplest approach — define variables directly in your YAML file:

# Inline variables — defined directly in the pipeline YAML.
# These are visible in version control (don't put secrets here!).
variables:
  buildConfiguration: 'Release'       # Simple name/value pair
  dotnetVersion: '8.0.x'              # .NET SDK version to install
  projectPath: 'src/MyApp/MyApp.csproj'  # Path to the project file

steps:
  - script: dotnet build $(projectPath) --configuration $(buildConfiguration)
    displayName: 'Build in $(buildConfiguration) mode'

Variable Groups (from Library)

Variable groups live in Azure DevOps Library and can be shared across multiple pipelines. They're ideal for environment-specific configuration:

# Variable groups — defined in Azure DevOps Library, referenced here.
# Go to Pipelines → Library → + Variable group to create them.
variables:
  - group: 'Production-Settings'     # Links to a variable group in Library
  - group: 'Shared-Credentials'      # Can link multiple groups
  - name: localVar                    # You can mix groups with inline vars
    value: 'hello'

Secret Variables

# Secret variables — marked as secret in Library or pipeline settings.
# They're masked in logs (replaced with ***) and can't be printed.
# You CANNOT define secrets inline in YAML — use Library or pipeline UI.
variables:
  - group: 'My-Secrets'              # Contains: dbPassword (secret), apiKey (secret)

steps:
  - script: |
      # Secret is available as environment variable but MASKED in logs.
      # This will print: "Password is ***"
      echo "Password is $(dbPassword)"
    displayName: 'Use secret variable'
    env:
      DB_PASSWORD: $(dbPassword)      # Map to environment variable for scripts
Secret variables are NEVER printed in logs, even with echo. They're available as environment variables but masked with ***. If you need to pass secrets between jobs, use the env: mapping or Azure Key Vault task — never write them to files or pipeline outputs.

Predefined System Variables

Azure Pipelines provides dozens of built-in variables automatically. These are the most commonly used:

Variable Example Value Use Case
Build.SourceBranch refs/heads/main Conditional logic based on branch
Build.SourceBranchName main Short branch name (no refs/heads/)
Build.BuildId 1247 Unique build number for tagging
Build.Repository.Name MyApp Repo name for multi-repo pipelines
System.PullRequest.PullRequestId 42 PR number (only set in PR builds)
Agent.OS Linux Cross-platform script logic

Variable Scoping

Variables can be scoped at different levels. A more specific scope overrides a broader one:

# Variable scoping — pipeline > stage > job.
# More specific scopes override broader ones.
variables:
  environment: 'development'          # Pipeline-level (default)

stages:
  - stage: Build
    variables:
      environment: 'ci'               # Stage-level overrides pipeline-level
    jobs:
      - job: CompileApp
        variables:
          environment: 'build'        # Job-level overrides stage-level
        steps:
          - script: echo $(environment)  # Prints: "build"
            displayName: 'Show environment'

  - stage: Deploy
    jobs:
      - job: DeployApp
        steps:
          - script: echo $(environment)  # Prints: "development" (pipeline-level)
            displayName: 'Show environment'

The Three Variable Syntaxes

This is the most confusing part of Azure Pipelines for newcomers. There are three different syntaxes, each evaluated at a different time:

Syntax Evaluated When Use Case Example
$(varName) Runtime (before each task) Most common — task inputs, scripts $(Build.BuildId)
${{ variables.varName }} Compile time (when pipeline is parsed) Template expressions, conditional insertion ${{ variables.env }}
$[variables.varName] Runtime (for conditions only) Dynamic conditions on steps/jobs condition: eq($[variables.deploy], 'true')
# Demonstrating all three variable syntaxes:
variables:
  myVar: 'hello'

steps:
  # 1. Macro syntax — $(varName) — most common, evaluated at runtime
  - script: echo $(myVar)
    displayName: 'Macro syntax'

  # 2. Template expression — ${{ }} — evaluated at COMPILE time
  #    (before the pipeline even starts running)
  - ${{ if eq(variables.myVar, 'hello') }}:
    - script: echo "Compile-time check passed"
      displayName: 'Template expression'

  # 3. Runtime expression — $[ ] — used in conditions
  - script: echo "This runs conditionally"
    displayName: 'Runtime condition'
    condition: eq(variables.myVar, 'hello')

Parameters

Parameters are compile-time inputs that users provide when queueing a pipeline. Unlike variables (which can change at runtime), parameters are resolved before the pipeline starts — they become fixed values baked into the compiled YAML.

Why Parameters Instead of Variables?

  • Type safety — parameters have types (string, number, boolean, object) and validation
  • Allowed values — you can restrict inputs to a predefined list
  • Default values — provide sensible defaults so users don't have to fill in everything
  • UI integration — parameters appear as form fields when queueing manually
# Parameters — typed inputs resolved at queue/compile time.
# Users see these as form fields when they manually run the pipeline.
parameters:
  # String parameter with allowed values (dropdown in UI)
  - name: environment
    displayName: 'Target Environment'
    type: string
    default: 'staging'
    values:
      - development
      - staging
      - production

  # Boolean parameter (checkbox in UI)
  - name: runTests
    displayName: 'Run test suite?'
    type: boolean
    default: true

  # Number parameter
  - name: parallelism
    displayName: 'Test parallelism level'
    type: number
    default: 4

  # Object parameter (complex structured data)
  - name: deployRegions
    displayName: 'Regions to deploy to'
    type: object
    default:
      - eastus
      - westeurope
      - southeastasia

trigger:
  - main

pool:
  vmImage: 'ubuntu-latest'

steps:
  # Reference parameters with ${{ parameters.name }} syntax
  - script: echo "Deploying to ${{ parameters.environment }}"
    displayName: 'Deploy to ${{ parameters.environment }}'

  # Use boolean parameter in condition
  - ${{ if eq(parameters.runTests, true) }}:
    - script: dotnet test --parallel ${{ parameters.parallelism }}
      displayName: 'Run tests with parallelism=${{ parameters.parallelism }}'

  # Iterate over object parameter
  - ${{ each region in parameters.deployRegions }}:
    - script: echo "Deploying to ${{ region }}"
      displayName: 'Deploy → ${{ region }}'
Parameters vs Variables — when to use which? Use parameters when you want user input at queue time with type validation and restricted values. Use variables when values come from the environment, are computed during the run, or need to change between jobs/stages.

Expressions & Functions

Expressions let you compute values, evaluate conditions, and transform data within your pipeline. Azure Pipelines provides a rich library of built-in functions:

Expression Syntax

  • ${{ expression }} — Compile-time (template expressions) — evaluated when YAML is parsed
  • $[ expression ] — Runtime — evaluated during pipeline execution

Key Functions

Function Syntax Example Result
Equality eq(a, b) eq(variables.env, 'prod') true or false
Not equal ne(a, b) ne(variables.env, 'dev') true or false
Contains contains(string, substring) contains(variables['Build.SourceBranch'], 'release') true or false
Starts with startsWith(string, prefix) startsWith(variables['Build.SourceBranch'], 'refs/heads/feature/') true or false
Format format(fmt, args...) format('v{0}.{1}', variables.major, variables.minor) v1.3
Coalesce coalesce(a, b, c) coalesce(variables.custom, 'default') First non-empty value
Join join(separator, array) join(',', parameters.regions) eastus,westeurope

Status Check Functions

These functions check the outcome of previous steps/jobs — essential for cleanup and notification logic:

# Status functions — control flow based on previous step outcomes.
steps:
  - script: dotnet build
    displayName: 'Build application'

  - script: dotnet test
    displayName: 'Run tests'

  # succeeded() — only runs if ALL previous steps passed (DEFAULT behavior)
  - script: echo "Everything passed!"
    displayName: 'Success notification'
    condition: succeeded()

  # failed() — only runs if a previous step FAILED
  - script: |
      echo "Build or tests failed — sending alert"
      curl -X POST $(slackWebhook) -d '{"text":"Pipeline failed!"}'
    displayName: 'Failure notification'
    condition: failed()

  # always() — runs regardless of success/failure (cleanup, logging)
  - script: |
      echo "Cleaning up temporary files..."
      rm -rf ./temp
    displayName: 'Cleanup (always runs)'
    condition: always()

  # canceled() — runs only if the pipeline was manually canceled
  - script: echo "Pipeline was canceled by user"
    displayName: 'Cancellation handler'
    condition: canceled()

Conditions & Control Flow

Conditions let you control which steps, jobs, or stages execute based on variables, parameters, branch names, or previous outcomes. This is how you build pipelines that behave differently for feature branches vs main, or skip expensive steps during PR validation.

Conditions on Steps

# Conditions — control which steps execute.
variables:
  isMain: $[eq(variables['Build.SourceBranch'], 'refs/heads/main')]

steps:
  # Always runs
  - script: dotnet build
    displayName: 'Build'

  # Only runs on main branch
  - script: dotnet publish -o ./output
    displayName: 'Publish artifacts'
    condition: eq(variables['Build.SourceBranch'], 'refs/heads/main')

  # Only runs on feature branches (NOT main)
  - script: echo "Feature branch build — skipping publish"
    displayName: 'Feature branch notice'
    condition: ne(variables['Build.SourceBranch'], 'refs/heads/main')

  # Combining conditions with and() / or()
  - script: ./deploy.sh
    displayName: 'Deploy to production'
    condition: |
      and(
        succeeded(),
        eq(variables['Build.SourceBranch'], 'refs/heads/main'),
        ne(variables['Build.Reason'], 'PullRequest')
      )

Conditional Insertion with ${{ if }}

Template expressions let you conditionally include or exclude entire blocks of YAML at compile time. This is different from runtime conditions — the YAML is literally different depending on the parameter:

# Conditional insertion — compile-time if/else.
# The YAML is different depending on the parameter value.
parameters:
  - name: environment
    type: string
    default: 'staging'
    values: [development, staging, production]

stages:
  - stage: Build
    jobs:
      - job: Compile
        steps:
          - script: dotnet build
            displayName: 'Build'

  # This stage ONLY EXISTS in the compiled YAML if environment is 'production'
  - ${{ if eq(parameters.environment, 'production') }}:
    - stage: Approval
      jobs:
        - job: WaitForApproval
          pool: server            # Agentless job (runs on server)
          steps:
            - task: ManualValidation@1
              inputs:
                notifyUsers: 'release-managers@company.com'
                instructions: 'Please approve production deployment'

  - stage: Deploy
    jobs:
      - job: DeployApp
        steps:
          # Different script based on environment
          - ${{ if eq(parameters.environment, 'production') }}:
            - script: ./deploy.sh --env prod --region all
              displayName: 'Deploy to production (all regions)'
          - ${{ if eq(parameters.environment, 'staging') }}:
            - script: ./deploy.sh --env staging --region eastus
              displayName: 'Deploy to staging (single region)'
          - ${{ if eq(parameters.environment, 'development') }}:
            - script: ./deploy.sh --env dev --region local
              displayName: 'Deploy to development'
Compile-time vs Runtime conditions: Use ${{ if }} when you want to include/exclude entire YAML blocks (steps, jobs, stages). Use condition: when you want a step to exist but potentially be skipped at runtime. The distinction matters because compile-time expressions can't see runtime values (like output variables from previous jobs).

Resources

Resources let your pipeline access external items — other repositories, Docker containers, other pipelines, or packages. The most common use case is multi-repo checkout (when your code spans multiple repositories).

Repository Resources (Multi-Repo Checkout)

# Multi-repo checkout — access code from multiple repositories.
# By default, pipelines only check out the repo where the YAML lives.
resources:
  repositories:
    # Reference another repo in the same Azure DevOps project
    - repository: shared-templates     # Alias (your choice)
      type: git                        # Azure Repos Git
      name: MyProject/shared-templates # Project/RepoName
      ref: refs/heads/main             # Branch to check out

    # Reference a GitHub repository
    - repository: open-source-lib
      type: github
      name: myorg/open-source-lib      # GitHub org/repo
      endpoint: 'GitHub-ServiceConnection'  # Service connection name

trigger:
  - main

pool:
  vmImage: 'ubuntu-latest'

steps:
  # Check out the primary repo (where this YAML lives)
  - checkout: self
    path: 'main-app'

  # Check out the shared templates repo
  - checkout: shared-templates
    path: 'templates'

  # Check out the GitHub repo
  - checkout: open-source-lib
    path: 'lib'

  # Now you can access files from all three repos
  - script: |
      ls $(Pipeline.Workspace)/main-app
      ls $(Pipeline.Workspace)/templates
      ls $(Pipeline.Workspace)/lib
    displayName: 'List all checked-out repos'

Container Resources

Run your job inside a Docker container — useful when you need specific tools, runtimes, or a reproducible environment:

# Container resources — run jobs inside Docker containers.
# The job steps execute inside the container, not on the host agent.
resources:
  containers:
    - container: node-env
      image: 'node:20-alpine'          # Docker Hub image
    - container: dotnet-env
      image: 'mcr.microsoft.com/dotnet/sdk:8.0'  # Microsoft Container Registry

pool:
  vmImage: 'ubuntu-latest'

jobs:
  - job: BuildNode
    container: node-env                 # This job runs inside node:20-alpine
    steps:
      - script: |
          node --version
          npm ci
          npm run build
        displayName: 'Build Node.js app'

  - job: BuildDotNet
    container: dotnet-env               # This job runs inside dotnet/sdk:8.0
    steps:
      - script: |
          dotnet --version
          dotnet build
        displayName: 'Build .NET app'

Your First Complete CI Pipeline

Let's build a production-ready CI pipeline for a .NET application from scratch. This pipeline restores dependencies, builds, runs tests, and publishes artifacts — everything a real project needs.

# ═══════════════════════════════════════════════════════════════
# Complete CI Pipeline — .NET Application
# File: azure-pipelines.yml (place in repository root)
# ═══════════════════════════════════════════════════════════════

# ─── Trigger: Run on push to main or release branches ────────
trigger:
  branches:
    include:
      - main
      - release/*
  paths:
    exclude:
      - docs/**              # Don't rebuild for docs-only changes
      - '*.md'               # Don't rebuild for README changes

# ─── PR Validation: Run on PRs targeting main ────────────────
pr:
  branches:
    include:
      - main
  drafts: false              # Skip draft PRs

# ─── Variables: Shared configuration ─────────────────────────
variables:
  buildConfiguration: 'Release'
  dotnetVersion: '8.0.x'
  solution: '**/*.sln'
  testProjects: '**/*Tests/*.csproj'
  publishProject: 'src/MyApp.Api/MyApp.Api.csproj'

# ─── Pool: Microsoft-hosted Ubuntu agent ─────────────────────
pool:
  vmImage: 'ubuntu-latest'

# ─── Steps: The actual work ──────────────────────────────────
steps:
  # Step 1: Check out source code
  - checkout: self
    fetchDepth: 0            # Full history (needed for versioning tools)
    clean: true              # Start with clean workspace every time

  # Step 2: Install the correct .NET SDK version
  - task: UseDotNet@2
    displayName: 'Install .NET SDK $(dotnetVersion)'
    inputs:
      packageType: 'sdk'
      version: '$(dotnetVersion)'

  # Step 3: Restore NuGet packages (downloads dependencies)
  - task: DotNetCoreCLI@2
    displayName: 'Restore NuGet packages'
    inputs:
      command: 'restore'
      projects: '$(solution)'
      feedsToUse: 'select'   # Use configured package feeds

  # Step 4: Build the solution in Release mode
  - task: DotNetCoreCLI@2
    displayName: 'Build solution ($(buildConfiguration))'
    inputs:
      command: 'build'
      projects: '$(solution)'
      arguments: '--configuration $(buildConfiguration) --no-restore'
      # --no-restore: Skip restore (we already did it in step 3)

  # Step 5: Run unit tests with code coverage
  - task: DotNetCoreCLI@2
    displayName: 'Run unit tests'
    inputs:
      command: 'test'
      projects: '$(testProjects)'
      arguments: >-
        --configuration $(buildConfiguration)
        --no-build
        --collect:"XPlat Code Coverage"
        --logger trx
        --results-directory $(Agent.TempDirectory)/TestResults
      # --no-build: Don't rebuild (we built in step 4)
      # --collect: Generate code coverage data
      # --logger trx: Output test results in TRX format for Azure DevOps

  # Step 6: Publish code coverage results to Azure DevOps
  - task: PublishCodeCoverageResults@2
    displayName: 'Publish code coverage'
    inputs:
      summaryFileLocation: '$(Agent.TempDirectory)/TestResults/**/coverage.cobertura.xml'

  # Step 7: Publish the application (create deployment package)
  # Only runs on main branch (not on PR builds — saves time)
  - task: DotNetCoreCLI@2
    displayName: 'Publish application'
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    inputs:
      command: 'publish'
      projects: '$(publishProject)'
      arguments: '--configuration $(buildConfiguration) --output $(Build.ArtifactStagingDirectory)'
      zipAfterPublish: true  # Zip the output for easier deployment

  # Step 8: Upload build artifacts to Azure DevOps
  # Only runs on main branch (artifact is only useful for deployment)
  - task: PublishBuildArtifacts@1
    displayName: 'Upload build artifacts'
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    inputs:
      PathtoPublish: '$(Build.ArtifactStagingDirectory)'
      ArtifactName: 'drop'   # Name of the artifact (used in release pipeline)
      publishLocation: 'Container'
This pipeline follows best practices: It separates restore/build/test/publish into distinct steps (for clear failure diagnosis), skips expensive steps on PR builds (publish + artifacts), uses --no-restore and --no-build flags to avoid redundant work, and collects both test results and code coverage for visibility in Azure DevOps.

Debugging Pipelines

When pipelines fail (and they will), you need tools to diagnose what went wrong. Here are the most effective debugging techniques:

Enable Verbose Logging

# Method 1: Set System.Debug variable to get verbose logs.
# Add this temporarily when troubleshooting, then remove it.
variables:
  System.Debug: true         # Enables detailed diagnostic logging
                             # Shows every variable, every file path,
                             # every environment variable — very noisy!

steps:
  - script: dotnet build
    displayName: 'Build (with verbose logging enabled)'
# Method 2: Enable debug on a single step (less noisy).
steps:
  - script: |
      echo "Build.SourceBranch = $(Build.SourceBranch)"
      echo "Build.Reason = $(Build.Reason)"
      echo "Agent.OS = $(Agent.OS)"
      echo "Pipeline.Workspace = $(Pipeline.Workspace)"
      pwd
      ls -la
    displayName: 'Debug — print all context'

Common YAML Errors and Fixes

Error Cause Fix
"unexpected value 'steps'" Indentation wrong — steps at wrong level Align steps: under job: (2 spaces per level)
"A template expression is not allowed" Using ${{ }} where only $[ ] is allowed Use $[ ] in condition: fields for runtime values
"Pool not found" Agent pool name misspelled or no access Check pool name in Project Settings → Agent Pools
"Variable is empty" Variable group not linked or wrong scope Verify group is linked in pipeline settings
"Mapping values not allowed here" Tab character in YAML (use spaces only) Replace all tabs with spaces (2 per indent level)

Validate YAML Before Pushing

# Validate your pipeline YAML locally before pushing.
# Option 1: Use the Azure DevOps REST API (requires PAT token)
az pipelines validate \
  --organization https://dev.azure.com/myorg \
  --project MyProject \
  --yaml-path azure-pipelines.yml

# Option 2: Use the VS Code extension "Azure Pipelines"
# It provides real-time YAML validation, IntelliSense,
# and schema checking as you type.

# Option 3: Use the "Validate" button in Azure DevOps UI
# Edit pipeline → click "Validate" in top-right → shows errors

Exercises

Hands-On Exercise 1

Create a Node.js CI Pipeline

Objective: Build a pipeline that validates a Node.js application on every push to main.

  1. Create a new file azure-pipelines.yml in a Node.js project root
  2. Add a CI trigger for the main branch only
  3. Use ubuntu-latest pool with NodeTool@0 task (version 20.x)
  4. Add steps: npm ci (install) → npm run lint (lint) → npm test (test) → npm run build (build)
  5. Add path filters to skip docs/** and *.md changes

Verify: Push a change to src/ — pipeline should trigger. Push a change to README.md — pipeline should NOT trigger.

CI Triggers Path Filters Node.js
Hands-On Exercise 2

PR Validation with Branch and Path Filters

Objective: Add PR validation that only runs for meaningful code changes.

  1. Add a pr: section targeting main and release/* branches
  2. Include path filters for src/** and tests/**
  3. Exclude docs/**, *.md, and .github/**
  4. Set drafts: false to skip draft PRs
  5. Add a step that prints the PR number using $(System.PullRequest.PullRequestId)

Verify: Create a PR that only changes README.md — pipeline should NOT trigger. Create a PR that changes src/app.ts — pipeline should trigger and print the PR number.

PR Triggers Path Filters System Variables
Hands-On Exercise 3

Parameterized Pipeline with Conditional Deployment

Objective: Create a pipeline where users choose the target environment at queue time.

  1. Define a parameters section with:
    • environment (string, values: dev/staging/prod, default: staging)
    • runTests (boolean, default: true)
    • verboseLogging (boolean, default: false)
  2. Conditionally set System.Debug based on verboseLogging parameter
  3. Use ${{ if }} to only include the test stage when runTests is true
  4. Use ${{ if }} to add an approval stage only for production
  5. Print the selected environment in the deploy step

Verify: Queue the pipeline with environment=production — you should see the approval stage. Queue with environment=dev — no approval stage should appear.

Parameters Conditions Compile-Time Insertion
Hands-On Exercise 4

Variable Groups and Secrets

Objective: Use variable groups to manage environment-specific database connection strings securely.

  1. In Azure DevOps Library, create two variable groups:
    • DB-Settings-Staging with: dbHost=staging-db.database.windows.net, dbName=myapp-staging, dbPassword (marked secret)
    • DB-Settings-Production with: dbHost=prod-db.database.windows.net, dbName=myapp-prod, dbPassword (marked secret)
  2. Create a pipeline that links the appropriate group based on branch (main → Production, develop → Staging)
  3. Add a step that uses the env: mapping to pass dbPassword as an environment variable
  4. Add a step that prints $(dbHost) and $(dbName) (non-secret) and attempts to print $(dbPassword) (should show ***)

Verify: Run the pipeline — confirm that dbHost and dbName print correctly, but dbPassword shows as *** in the logs.

Variable Groups Secrets Library

Next in the Bootcamp

In Module 5: Azure Pipelines — Agents, Pools & Advanced Build, we'll cover self-hosted agents, agent pools, demands, pipeline templates, multi-stage deployments, environments, approvals, and deployment strategies (blue-green, canary, rolling).