How Pipelines Execute
In Module 4, we wrote pipeline YAML — triggers, variables, expressions, and conditions. But where does that YAML actually execute? The answer is agents — software processes that run your pipeline jobs on physical or virtual machines.
Think of it this way: agents are like construction workers. The pipeline YAML is the blueprint, agent pools are work crews, and each agent is a worker who picks up a job, completes it, and reports back. Microsoft-hosted agents are contractors — fresh each time, no memory of previous projects. Self-hosted agents are full-time employees — persistent, familiar with your codebase, and equipped with specialized tools.
The Execution Model
When you push code and a pipeline triggers, here's what happens behind the scenes:
- Pipeline service queues the job — Azure DevOps evaluates triggers, parses YAML, and creates a job request
- Job lands in an agent pool — the request is routed to the specified pool (e.g.,
vmImage: 'ubuntu-latest') - Agent picks up the job — an available agent with matching capabilities claims the work
- Agent executes steps sequentially — checkout, restore, build, test, publish — reporting progress in real-time
- Agent reports completion — success/failure status, logs, and artifacts are sent back to the service
flowchart LR
A[Code Push] --> B[Pipeline Service]
B --> C{Agent Pool}
C --> D[Agent 1]
C --> E[Agent 2]
C --> F[Agent N]
D --> G[Execute Job]
G --> H[Report Results]
H --> B
Agent Types at a Glance
| Aspect | Microsoft-Hosted | Self-Hosted |
|---|---|---|
| Provisioning | Fresh VM spun up for each job | Persistent machine you manage |
| State | Clean every time — no leftover files | Persists between jobs (caches, tools) |
| Maintenance | Microsoft handles updates/patches | You handle updates/patches |
| Network | Public internet only | Can access private VNETs/on-prem |
| Cost | Free tier + per-minute billing | Your infrastructure costs only |
| Best for | Standard builds, open-source, small teams | Large-scale CI, private network access, GPU/custom hardware |
Microsoft-Hosted Agents
Microsoft-hosted agents are the easiest way to get started — specify an image name and Azure DevOps provisions a fresh VM for your job. No setup, no maintenance, no infrastructure to manage.
Available Images
| Image | OS | Key Tools Pre-Installed | Best For |
|---|---|---|---|
ubuntu-latest |
Ubuntu 22.04 | .NET 8, Node 20, Python 3.11, Docker, Go, Java 17 | Most workloads (fastest, cheapest) |
ubuntu-24.04 |
Ubuntu 24.04 | .NET 8/9, Node 22, Python 3.12, Docker | Bleeding-edge Linux builds |
windows-latest |
Windows Server 2022 | Visual Studio 2022, .NET Framework 4.8, .NET 8 | .NET Framework, WPF, WinForms, UWP |
windows-2019 |
Windows Server 2019 | Visual Studio 2019, .NET Framework 4.8 | Legacy .NET Framework apps |
macos-latest |
macOS 14 (Sonoma) | Xcode 15, Swift 5.9, CocoaPods, Homebrew | iOS/macOS builds, Swift projects |
macos-13 |
macOS 13 (Ventura) | Xcode 14/15, Swift 5.8 | Older Xcode version requirements |
Limitations & Cost Model
- Job timeout: 60 minutes (free tier) / 360 minutes (paid tier, 6 hours max)
- No persistent state: VM is destroyed after each job — no caching between jobs without
Cache@2task - No private network: Cannot access resources behind firewalls or VNETs
- Disk space: ~14 GB free on Ubuntu, ~14 GB on Windows
- Free tier: 1,800 minutes/month for private repos (public repos get unlimited)
- Paid: $40/month per additional parallel job (run multiple jobs simultaneously)
Specifying Different Pools per Job
# Use different agent images for different jobs.
# This is common when you need OS-specific testing.
trigger:
- main
stages:
- stage: Build
jobs:
# ─── Job 1: Build on Linux (fast, cheap) ─────────────────
- job: BuildLinux
pool:
vmImage: 'ubuntu-latest' # Most common choice
steps:
- script: dotnet build
displayName: 'Build on Linux'
# ─── Job 2: Build on Windows (for .NET Framework) ────────
- job: BuildWindows
pool:
vmImage: 'windows-latest' # Needed for .NET Framework
steps:
- script: dotnet build
displayName: 'Build on Windows'
# ─── Job 3: Build on macOS (for iOS/Xcode) ──────────────
- job: BuildMac
pool:
vmImage: 'macos-latest' # Needed for Xcode/iOS
steps:
- script: xcodebuild -version
displayName: 'Verify Xcode version'
ubuntu-latest as your default — it's the fastest to provision (~10s vs ~60s for Windows) and cheapest per minute. Only use Windows/macOS when your build genuinely requires it (e.g., .NET Framework 4.8, Xcode signing).
Self-Hosted Agents
When Microsoft-hosted agents don't meet your needs — private network access, custom hardware (GPUs), compliance requirements, or cost optimization at scale — you run your own agents.
When You Need Self-Hosted
- Private network access: Deploy to databases, VMs, or services behind a firewall/VNET
- Custom hardware: GPU builds (ML model training), ARM64, specialized test devices
- Cost at scale: If you run >10 parallel jobs, self-hosted can be cheaper than $40/job/month
- Compliance: Data residency requirements — code never leaves your network
- Build speed: Persistent caches, pre-pulled Docker images, warm NuGet/npm caches
Installing the Agent (Linux)
#!/bin/bash
# ═══════════════════════════════════════════════════════════════
# Azure DevOps Self-Hosted Agent Installation — Ubuntu/Debian
# Run this on the machine that will become your build agent.
# Prerequisites: curl, systemd, a PAT token with "Agent Pools (Read & Manage)" scope
# ═══════════════════════════════════════════════════════════════
# ─── Step 1: Create a dedicated agent user (don't run as root!) ───
sudo useradd -m -s /bin/bash azagent
sudo usermod -aG docker azagent # Allow Docker access (if needed)
# ─── Step 2: Create agent directory ──────────────────────────────
sudo mkdir -p /opt/azure-agent
sudo chown azagent:azagent /opt/azure-agent
cd /opt/azure-agent
# ─── Step 3: Download the latest agent package ───────────────────
# Check https://github.com/microsoft/azure-pipelines-agent/releases
# for the latest version
AGENT_VERSION="3.240.1"
curl -LO "https://vstsagentpackage.azureedge.net/agent/${AGENT_VERSION}/vsts-agent-linux-x64-${AGENT_VERSION}.tar.gz"
tar xzf "vsts-agent-linux-x64-${AGENT_VERSION}.tar.gz"
# ─── Step 4: Configure the agent ────────────────────────────────
# This prompts for: server URL, PAT token, agent pool, agent name
sudo -u azagent ./config.sh \
--unattended \
--url https://dev.azure.com/YOUR_ORG \
--auth pat \
--token YOUR_PAT_TOKEN \
--pool "Default" \
--agent "$(hostname)" \
--acceptTeeEula \
--replace
# ─── Step 5: Install and start as a systemd service ─────────────
sudo ./svc.sh install azagent # Install service (runs as azagent user)
sudo ./svc.sh start # Start the agent service
sudo ./svc.sh status # Verify it's running
# ─── Step 6: Verify in Azure DevOps ─────────────────────────────
# Go to: Project Settings → Agent Pools → Default → Agents tab
# Your agent should appear as "Online" with a green icon.
echo "Agent installed! Check Azure DevOps portal for status."
Agent Lifecycle Commands
| Action | Command | When to Use |
|---|---|---|
| Configure | ./config.sh |
First-time setup or re-register |
| Run interactively | ./run.sh |
Testing/debugging (Ctrl+C to stop) |
| Install service | sudo ./svc.sh install |
Production — survives reboots |
| Start/Stop/Status | sudo ./svc.sh start|stop|status |
Manage running service |
| Update agent | Automatic (or ./config.sh --replace) |
Azure DevOps auto-updates agents |
| Remove agent | ./config.sh remove --auth pat --token TOKEN |
Decommissioning the machine |
workspace: clean: all in your pipeline, schedule periodic disk cleanup, and monitor disk space. A full disk is the #1 cause of mysterious self-hosted agent failures.
Security Best Practices
- Never run as root: Create a dedicated
azagentuser with minimal permissions - Network isolation: Place agents in a dedicated subnet with outbound-only access to Azure DevOps
- Rotate PAT tokens: Use short-lived tokens and rotate quarterly
- Restrict pool access: Use pipeline permissions to control which pipelines can use which pools
- Ephemeral agents: For maximum security, use VMSS-based auto-scaling agents that are destroyed after each job
Agent Pools & Capabilities
Agent pools are logical groups of agents that share the same configuration and purpose. They let you route specific types of work to specific sets of machines.
Default Pools
- Azure Pipelines: The built-in pool for Microsoft-hosted agents (ubuntu-latest, windows-latest, etc.)
- Default: The default pool for self-hosted agents — all self-hosted agents go here unless you create custom pools
Creating Custom Pools
Custom pools let you organize agents by capability, environment, or purpose:
GPU-Agents— machines with NVIDIA GPUs for ML trainingProduction-Deploy— agents in production VNET for deploymentsLinux-ARM64— ARM-based agents for cross-compilationHigh-Memory— 64GB+ RAM agents for large .NET solutions
Capabilities & Demands
Every agent advertises its capabilities (installed software, hardware specs). Pipelines specify demands to match jobs to agents that have what they need.
# ═══════════════════════════════════════════════════════════════
# Capabilities and Demands — matching jobs to the right agents
# ═══════════════════════════════════════════════════════════════
# System capabilities are auto-detected:
# Agent.OS = Linux
# Agent.OSArchitecture = X64
# dotnet = /usr/bin/dotnet
# docker = /usr/bin/docker
# node = /usr/local/bin/node
# User capabilities are manually added in Azure DevOps:
# gpu = nvidia-a100
# environment = production
# disk-type = ssd
# ─── Pipeline demands: "I need an agent with Docker AND GPU" ──
pool:
name: 'GPU-Agents' # Use a specific self-hosted pool
demands:
- docker # Agent must have Docker installed
- gpu -equals nvidia-a100 # Agent must have nvidia-a100 GPU
- Agent.OS -equals Linux # Must be Linux
jobs:
- job: TrainModel
steps:
- script: |
nvidia-smi # Verify GPU is available
python train.py --epochs 100
displayName: 'Train ML model on GPU'
flowchart TD
A[Pipeline Job] --> B{Pool Selection}
B -->|"pool: vmImage: ubuntu-latest"| C[Azure Pipelines Pool]
B -->|"pool: name: Default"| D[Default Pool]
B -->|"pool: name: GPU-Agents"| E[GPU-Agents Pool]
C --> F[MS-Hosted VM]
D --> G{Demands Check}
E --> H{Demands Check}
G -->|Match| I[Agent picks job]
G -->|No match| J[Job waits]
H -->|Match| K[GPU Agent picks job]
H -->|No match| L[Job waits]
Pool Maintenance Tips
- Monitor offline agents: Set alerts for agents going offline — a dead agent delays all jobs in that pool
- Parallel job limits: Each pool has a concurrency limit — if all agents are busy, new jobs queue
- Agent version: Azure DevOps auto-updates agents, but verify all agents in a pool run the same version
- Maintenance jobs: Schedule weekly pipelines that clean up Docker images, old workspaces, and temp files on self-hosted agents
Build Task Ecosystem
Tasks are pre-built, versioned automation blocks that handle common CI/CD actions. Instead of writing shell scripts for everything, you use tasks — they handle cross-platform differences, error reporting, and integration with Azure DevOps features (test results, code coverage, artifacts).
Task Categories
| Category | Examples | Purpose |
|---|---|---|
| Build | DotNetCoreCLI@2, Maven@3, Gradle@3 | Compile, restore dependencies, package |
| Utility | Cache@2, CopyFiles@2, DownloadSecureFile@1 | File operations, caching, downloads |
| Test | PublishTestResults@2, PublishCodeCoverageResults@2 | Report test outcomes to Azure DevOps |
| Package | NuGetCommand@2, Npm@1, PipAuthenticate@1 | Package management and publishing |
| Deploy | AzureWebApp@1, KubernetesManifest@1, Docker@2 | Deploy to Azure or containers |
Key Build Tasks
# ═══════════════════════════════════════════════════════════════
# Essential Build Tasks — the most commonly used in CI pipelines
# ═══════════════════════════════════════════════════════════════
steps:
# ─── .NET Build Task ──────────────────────────────────────────
- task: DotNetCoreCLI@2
displayName: 'Restore NuGet packages'
inputs:
command: 'restore' # restore, build, test, publish, pack, push
projects: '**/*.csproj' # Glob pattern for project files
feedsToUse: 'select' # Use feeds configured in NuGet.config
# ─── Node.js Version Task ─────────────────────────────────────
- task: NodeTool@0
displayName: 'Install Node.js 20'
inputs:
versionSpec: '20.x' # Semantic version range
checkLatest: true # Check for latest patch version
# ─── Python Version Task ──────────────────────────────────────
- task: UsePythonVersion@0
displayName: 'Use Python 3.12'
inputs:
versionSpec: '3.12'
addToPath: true # Add to PATH for subsequent steps
# ─── Go Version Task ──────────────────────────────────────────
- task: GoTool@0
displayName: 'Install Go 1.22'
inputs:
version: '1.22'
# ─── Docker Build & Push ──────────────────────────────────────
- task: Docker@2
displayName: 'Build and push Docker image'
inputs:
containerRegistry: 'myACR' # Service connection name
repository: 'myapp' # Image repository name
command: 'buildAndPush'
Dockerfile: '**/Dockerfile'
tags: |
$(Build.BuildId)
latest
Task Control Options
# Every task supports these control options.
steps:
- task: DotNetCoreCLI@2
displayName: 'Run integration tests'
inputs:
command: 'test'
projects: '**/*IntegrationTests.csproj'
continueOnError: true # Don't fail the job if this step fails
timeoutInMinutes: 15 # Kill the step after 15 minutes
retryCountOnTaskFailure: 2 # Retry up to 2 times on failure
condition: eq(variables['runIntegrationTests'], 'true')
env:
CONNECTION_STRING: $(dbConnectionString) # Map secret to env var
Script Tasks — When to Use Each
| Task | Shell | Cross-Platform | When to Use |
|---|---|---|---|
script: |
cmd (Windows) / bash (Linux/macOS) | Yes (auto-detects) | Default choice for simple commands |
bash: |
Bash always (even on Windows via Git Bash) | Yes | Bash-specific syntax needed cross-platform |
powershell: |
Windows PowerShell 5.1 | No (Windows only) | Windows-specific automation, .NET calls |
pwsh: |
PowerShell 7+ (Core) | Yes | Cross-platform PowerShell scripts |
Caching & Performance Optimization
Microsoft-hosted agents are ephemeral — every job starts on a fresh VM. Without caching, you re-download hundreds of megabytes of dependencies every single build. The Cache@2 task solves this by persisting files between pipeline runs.
How Pipeline Caching Works
- Cache key: A fingerprint (usually hash of lock files) that uniquely identifies the cache
- Cache hit: If the key matches a stored cache, files are restored (~5 seconds)
- Cache miss: If no match, the step runs normally — then saves the result for next time
- Cache scope: Caches are scoped to the branch (with fallback to the default branch)
# ═══════════════════════════════════════════════════════════════
# Caching Examples — Node.js, .NET, Python, Go
# ═══════════════════════════════════════════════════════════════
steps:
# ─── Node.js: Cache node_modules ──────────────────────────────
- task: Cache@2
displayName: 'Cache node_modules'
inputs:
key: 'npm | "$(Agent.OS)" | package-lock.json'
path: '$(System.DefaultWorkingDirectory)/node_modules'
# Key = "npm" + OS + hash of package-lock.json
# If package-lock.json changes → cache invalidated → fresh install
restoreKeys: |
npm | "$(Agent.OS)"
- script: npm ci # Skipped if cache hit is perfect
displayName: 'Install Node dependencies'
condition: ne(variables['CacheRestored'], 'true')
# ─── .NET: Cache NuGet packages ───────────────────────────────
- task: Cache@2
displayName: 'Cache NuGet packages'
inputs:
key: 'nuget | "$(Agent.OS)" | **/packages.lock.json'
path: '$(UserProfile)/.nuget/packages'
restoreKeys: |
nuget | "$(Agent.OS)"
# ─── Python: Cache pip packages ───────────────────────────────
- task: Cache@2
displayName: 'Cache pip packages'
inputs:
key: 'pip | "$(Agent.OS)" | requirements.txt'
path: '$(PIP_CACHE_DIR)'
restoreKeys: |
pip | "$(Agent.OS)"
# ─── Go: Cache Go modules ────────────────────────────────────
- task: Cache@2
displayName: 'Cache Go modules'
inputs:
key: 'go | "$(Agent.OS)" | go.sum'
path: '$(GOPATH)/pkg/mod'
restoreKeys: |
go | "$(Agent.OS)"
Cache Key Best Practices
- Always include
Agent.OS— Linux and Windows packages aren't interchangeable - Use lock file hashes —
package-lock.json,packages.lock.json,requirements.txt,go.sum - Use
restoreKeysfor partial matches — if the exact key misses, fall back to a broader match (slower restore but better than nothing) - Don't cache build outputs — only cache dependencies. Build outputs change every commit and waste cache space
CI Pipeline: .NET Applications
A complete CI pipeline for .NET 8 applications — restore, build, test with code coverage, and publish deployable artifacts.
# ═══════════════════════════════════════════════════════════════
# .NET 8 CI Pipeline — Complete Production-Ready Example
# File: azure-pipelines.yml
# ═══════════════════════════════════════════════════════════════
trigger:
branches:
include: [main, release/*]
paths:
exclude: ['docs/**', '*.md']
pr:
branches:
include: [main]
drafts: false
variables:
buildConfiguration: 'Release'
dotnetVersion: '8.0.x'
pool:
vmImage: 'ubuntu-latest'
steps:
# ─── Install .NET SDK ──────────────────────────────────────────
- task: UseDotNet@2
displayName: 'Install .NET $(dotnetVersion)'
inputs:
packageType: 'sdk'
version: '$(dotnetVersion)'
# ─── Cache NuGet packages ──────────────────────────────────────
- task: Cache@2
displayName: 'Cache NuGet'
inputs:
key: 'nuget | "$(Agent.OS)" | **/packages.lock.json'
path: '$(UserProfile)/.nuget/packages'
restoreKeys: nuget | "$(Agent.OS)"
# ─── Restore dependencies ─────────────────────────────────────
- task: DotNetCoreCLI@2
displayName: 'Restore packages'
inputs:
command: 'restore'
projects: '**/*.csproj'
# ─── Build the solution ────────────────────────────────────────
- task: DotNetCoreCLI@2
displayName: 'Build solution'
inputs:
command: 'build'
projects: '**/*.csproj'
arguments: '--configuration $(buildConfiguration) --no-restore'
# ─── Run tests with code coverage ─────────────────────────────
- task: DotNetCoreCLI@2
displayName: 'Run tests + coverage'
inputs:
command: 'test'
projects: '**/*Tests.csproj'
arguments: >-
--configuration $(buildConfiguration)
--no-build
--collect:"XPlat Code Coverage"
--logger trx
--results-directory $(Agent.TempDirectory)/TestResults
# ─── Publish test results to Azure DevOps ──────────────────────
- task: PublishTestResults@2
displayName: 'Publish test results'
inputs:
testResultsFormat: 'VSTest'
testResultsFiles: '$(Agent.TempDirectory)/TestResults/**/*.trx'
condition: succeededOrFailed() # Publish even if tests fail
# ─── Publish code coverage ─────────────────────────────────────
- task: PublishCodeCoverageResults@2
displayName: 'Publish code coverage'
inputs:
summaryFileLocation: '$(Agent.TempDirectory)/TestResults/**/coverage.cobertura.xml'
# ─── Publish deployable artifact (main branch only) ────────────
- task: DotNetCoreCLI@2
displayName: 'Publish app'
inputs:
command: 'publish'
projects: 'src/MyApp.Api/MyApp.Api.csproj'
arguments: '--configuration $(buildConfiguration) --no-build --output $(Build.ArtifactStagingDirectory)'
condition: eq(variables['Build.SourceBranch'], 'refs/heads/main')
- task: PublishPipelineArtifact@1
displayName: 'Upload artifact'
inputs:
targetPath: '$(Build.ArtifactStagingDirectory)'
artifactName: 'webapp'
condition: eq(variables['Build.SourceBranch'], 'refs/heads/main')
CI Pipeline: Node.js Applications
A Node.js CI pipeline with matrix strategy for testing across multiple Node versions simultaneously.
# ═══════════════════════════════════════════════════════════════
# Node.js CI Pipeline — Matrix Strategy (multiple versions)
# File: azure-pipelines.yml
# ═══════════════════════════════════════════════════════════════
trigger:
branches:
include: [main, develop]
paths:
exclude: ['docs/**', '*.md', '.vscode/**']
pr:
branches:
include: [main]
pool:
vmImage: 'ubuntu-latest'
# ─── Matrix: Test on Node 18 AND Node 20 simultaneously ──────
strategy:
matrix:
Node18:
nodeVersion: '18.x'
Node20:
nodeVersion: '20.x'
steps:
# ─── Install specified Node.js version ─────────────────────────
- task: NodeTool@0
displayName: 'Install Node.js $(nodeVersion)'
inputs:
versionSpec: '$(nodeVersion)'
# ─── Cache node_modules ────────────────────────────────────────
- task: Cache@2
displayName: 'Cache node_modules'
inputs:
key: 'npm | "$(Agent.OS)" | "$(nodeVersion)" | package-lock.json'
path: '$(System.DefaultWorkingDirectory)/node_modules'
restoreKeys: |
npm | "$(Agent.OS)" | "$(nodeVersion)"
# ─── Install dependencies (clean install) ──────────────────────
- script: npm ci
displayName: 'Install dependencies'
# ─── Run linter ────────────────────────────────────────────────
- script: npm run lint
displayName: 'Lint code (ESLint)'
# ─── Run tests with coverage ───────────────────────────────────
- script: npm test -- --coverage --ci --reporters=default --reporters=jest-junit
displayName: 'Run tests'
env:
JEST_JUNIT_OUTPUT_DIR: '$(System.DefaultWorkingDirectory)/test-results'
# ─── Publish test results ──────────────────────────────────────
- task: PublishTestResults@2
displayName: 'Publish test results'
inputs:
testResultsFormat: 'JUnit'
testResultsFiles: 'test-results/junit.xml'
condition: succeededOrFailed()
# ─── Build production bundle ───────────────────────────────────
- script: npm run build
displayName: 'Build production bundle'
# ─── Publish artifact (main branch + Node 20 only) ─────────────
- task: PublishPipelineArtifact@1
displayName: 'Upload build artifact'
inputs:
targetPath: '$(System.DefaultWorkingDirectory)/dist'
artifactName: 'webapp-$(nodeVersion)'
condition: |
and(
succeeded(),
eq(variables['Build.SourceBranch'], 'refs/heads/main'),
eq(variables['nodeVersion'], '20.x')
)
CI Pipeline: Python Applications
A Python CI pipeline with virtual environments, type checking (mypy), linting (ruff), and test coverage (pytest).
# ═══════════════════════════════════════════════════════════════
# Python CI Pipeline — Complete with linting, typing, testing
# File: azure-pipelines.yml
# ═══════════════════════════════════════════════════════════════
trigger:
branches:
include: [main, develop]
paths:
include: ['src/**', 'tests/**', 'requirements*.txt', 'pyproject.toml']
pr:
branches:
include: [main]
pool:
vmImage: 'ubuntu-latest'
variables:
pythonVersion: '3.12'
PIP_CACHE_DIR: '$(Pipeline.Workspace)/.pip'
steps:
# ─── Install Python ────────────────────────────────────────────
- task: UsePythonVersion@0
displayName: 'Use Python $(pythonVersion)'
inputs:
versionSpec: '$(pythonVersion)'
addToPath: true
# ─── Cache pip packages ────────────────────────────────────────
- task: Cache@2
displayName: 'Cache pip'
inputs:
key: 'pip | "$(Agent.OS)" | requirements.txt | requirements-dev.txt'
path: '$(PIP_CACHE_DIR)'
restoreKeys: pip | "$(Agent.OS)"
# ─── Install dependencies ──────────────────────────────────────
- script: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
displayName: 'Install dependencies'
# ─── Lint with Ruff (fast Python linter) ───────────────────────
- script: |
ruff check src/ tests/
ruff format --check src/ tests/
displayName: 'Lint & format check (Ruff)'
# ─── Type check with mypy ─────────────────────────────────────
- script: mypy src/ --ignore-missing-imports
displayName: 'Type check (mypy)'
# ─── Run tests with coverage ───────────────────────────────────
- script: |
pytest tests/ \
--cov=src \
--cov-report=xml:coverage.xml \
--cov-report=html:htmlcov \
--junitxml=test-results/results.xml \
-v
displayName: 'Run tests (pytest)'
# ─── Publish test results ──────────────────────────────────────
- task: PublishTestResults@2
displayName: 'Publish test results'
inputs:
testResultsFormat: 'JUnit'
testResultsFiles: 'test-results/results.xml'
condition: succeededOrFailed()
# ─── Publish code coverage ─────────────────────────────────────
- task: PublishCodeCoverageResults@2
displayName: 'Publish coverage'
inputs:
summaryFileLocation: 'coverage.xml'
# ─── Build wheel for distribution ─────────────────────────────
- script: |
pip install build
python -m build --wheel --outdir dist/
displayName: 'Build wheel'
condition: eq(variables['Build.SourceBranch'], 'refs/heads/main')
- task: PublishPipelineArtifact@1
displayName: 'Upload wheel'
inputs:
targetPath: 'dist'
artifactName: 'python-package'
condition: eq(variables['Build.SourceBranch'], 'refs/heads/main')
CI Pipeline: Container Builds
Building and pushing Docker images to Azure Container Registry (ACR) — the foundation for deploying to AKS, App Service, or Container Apps.
# ═══════════════════════════════════════════════════════════════
# Container CI Pipeline — Build, Scan, Push to ACR
# File: azure-pipelines.yml
# ═══════════════════════════════════════════════════════════════
trigger:
branches:
include: [main]
paths:
include: ['src/**', 'Dockerfile', 'docker-compose*.yml']
pr:
branches:
include: [main]
variables:
acrName: 'mycompanyacr'
imageName: 'myapp-api'
# Tag with build ID for traceability + "latest" for convenience
imageTag: '$(Build.BuildId)'
pool:
vmImage: 'ubuntu-latest'
steps:
# ─── Login to Azure Container Registry ────────────────────────
- task: Docker@2
displayName: 'Login to ACR'
inputs:
containerRegistry: 'acr-service-connection' # Service connection name
command: 'login'
# ─── Build Docker image (multi-stage Dockerfile) ───────────────
- task: Docker@2
displayName: 'Build image'
inputs:
containerRegistry: 'acr-service-connection'
repository: '$(imageName)'
command: 'build'
Dockerfile: 'Dockerfile'
buildContext: '.'
tags: |
$(imageTag)
latest
arguments: '--build-arg BUILD_VERSION=$(Build.BuildNumber)'
# ─── Scan image for vulnerabilities (Trivy) ───────────────────
- script: |
# Install Trivy scanner
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# Scan the built image — fail on HIGH/CRITICAL vulnerabilities
trivy image \
--severity HIGH,CRITICAL \
--exit-code 1 \
--no-progress \
$(acrName).azurecr.io/$(imageName):$(imageTag)
displayName: 'Scan image (Trivy)'
continueOnError: false # Block push if vulnerabilities found
# ─── Push image to ACR (only on main, after scan passes) ──────
- task: Docker@2
displayName: 'Push to ACR'
inputs:
containerRegistry: 'acr-service-connection'
repository: '$(imageName)'
command: 'push'
tags: |
$(imageTag)
latest
condition: |
and(
succeeded(),
eq(variables['Build.SourceBranch'], 'refs/heads/main')
)
# ─── Output the deployed image reference ──────────────────────
- script: |
echo "##vso[task.setvariable variable=deployedImage;isOutput=true]$(acrName).azurecr.io/$(imageName):$(imageTag)"
echo "Image pushed: $(acrName).azurecr.io/$(imageName):$(imageTag)"
displayName: 'Set output variable'
name: 'imageRef'
condition: eq(variables['Build.SourceBranch'], 'refs/heads/main')
Publishing Build Artifacts
Artifacts are the outputs of your build — compiled binaries, Docker images, test reports, or deployment packages. Publishing them makes outputs available to later stages (deployment) or for manual download.
Pipeline Artifacts vs Build Artifacts
| Feature | PublishPipelineArtifact@1 (Recommended) | PublishBuildArtifacts@1 (Legacy) |
|---|---|---|
| Speed | Faster (deduplication, parallel upload) | Slower (single-threaded) |
| Storage | Azure Artifacts backend (efficient) | File container (legacy) |
| Size limit | No practical limit | ~4 GB per artifact |
| YAML keyword | publish: shortcut available |
Must use full task syntax |
Publishing & Downloading Artifacts
# ═══════════════════════════════════════════════════════════════
# Artifact Publishing & Consumption Across Stages
# ═══════════════════════════════════════════════════════════════
stages:
# ─── Stage 1: Build & publish artifacts ────────────────────────
- stage: Build
jobs:
- job: BuildApp
pool:
vmImage: 'ubuntu-latest'
steps:
- script: dotnet publish -o $(Build.ArtifactStagingDirectory)/app
displayName: 'Build app'
# Publish using the task (full control)
- task: PublishPipelineArtifact@1
displayName: 'Publish app artifact'
inputs:
targetPath: '$(Build.ArtifactStagingDirectory)/app'
artifactName: 'webapp' # Name for download reference
publishLocation: 'pipeline'
# Also publish infrastructure scripts
- task: PublishPipelineArtifact@1
displayName: 'Publish infra scripts'
inputs:
targetPath: '$(System.DefaultWorkingDirectory)/infra'
artifactName: 'infrastructure'
# ─── Stage 2: Deploy (downloads artifacts from Build stage) ────
- stage: Deploy
dependsOn: Build
jobs:
- job: DeployApp
pool:
vmImage: 'ubuntu-latest'
steps:
# Download the artifact published in the Build stage
- task: DownloadPipelineArtifact@2
displayName: 'Download app artifact'
inputs:
artifactName: 'webapp'
targetPath: '$(Pipeline.Workspace)/webapp'
- task: DownloadPipelineArtifact@2
displayName: 'Download infra scripts'
inputs:
artifactName: 'infrastructure'
targetPath: '$(Pipeline.Workspace)/infra'
# Use the downloaded files
- script: |
ls -la $(Pipeline.Workspace)/webapp/
echo "Deploying from artifact..."
displayName: 'Deploy application'
Artifact Naming Conventions
webapp— main application binary/packagewebapp-$(Build.BuildId)— versioned artifact for traceabilitytest-results— test reports and coverage filesinfrastructure— Terraform/Bicep/ARM templatesdocker-compose— container orchestration files
Exercises
Create a .NET CI Pipeline with Caching and Test Results
Objective: Build a production-quality .NET pipeline that uses caching and publishes test results to Azure DevOps.
- Create a new .NET 8 Web API project:
dotnet new webapi -o MyApi && cd MyApi && dotnet new xunit -o MyApi.Tests - Add an
azure-pipelines.ymlwith:- CI trigger on
mainwith path exclude for*.md Cache@2task for NuGet packages (key onpackages.lock.json)- DotNetCoreCLI restore → build → test with
--collect:"XPlat Code Coverage" PublishTestResults@2andPublishCodeCoverageResults@2
- CI trigger on
- Push and verify: test results and coverage appear in the pipeline run summary
Verify: Check the "Tests" tab shows pass/fail counts. Check the "Code Coverage" tab shows coverage percentage.
Set Up a Self-Hosted Agent
Objective: Install and configure a self-hosted agent on your local machine, then run a pipeline on it.
- In Azure DevOps, go to Project Settings → Agent Pools → Add Pool (name: "My-Local-Pool")
- Create a PAT token with Agent Pools (Read & Manage) scope
- Download and install the agent following the platform-specific instructions
- Run
./config.sh(Linux/macOS) or.\config.cmd(Windows) — point to your org, use the PAT, select "My-Local-Pool" - Start the agent with
./run.shand verify it appears as "Online" in Azure DevOps - Create a pipeline that uses
pool: name: 'My-Local-Pool'and verify it runs on your machine
Verify: In the pipeline run logs, check Agent.Name matches your machine's hostname. Run hostname in a step to confirm.
Matrix Build: Node.js 18 + 20 on Ubuntu + Windows
Objective: Create a cross-platform matrix build that tests on 4 combinations simultaneously.
- Create a Node.js project with a simple test (e.g.,
npm init -y && npm install jest --save-dev) - Write a pipeline with a
strategy: matrix:section containing 4 entries:Linux_Node18: vmImage=ubuntu-latest, nodeVersion=18.xLinux_Node20: vmImage=ubuntu-latest, nodeVersion=20.xWindows_Node18: vmImage=windows-latest, nodeVersion=18.xWindows_Node20: vmImage=windows-latest, nodeVersion=20.x
- Add
maxParallel: 4to run all combinations simultaneously - Each combination: install Node →
npm ci→npm test
Verify: The pipeline run shows 4 parallel jobs. All 4 should pass. Check that each uses the correct Node version and OS.
Build and Push a Docker Image to ACR
Objective: Build a Docker image in a pipeline and push it to Azure Container Registry.
- Create a simple
Dockerfile(e.g., multi-stage .NET or Node.js app) - Create an Azure Container Registry in the Azure Portal (Basic tier is fine for testing)
- In Azure DevOps, create a Docker Registry service connection pointing to your ACR
- Write a pipeline that uses
Docker@2task to:- Build the image (tag with
$(Build.BuildId)) - Push to ACR (only on
mainbranch)
- Build the image (tag with
- Verify the image appears in your ACR (Azure Portal → Container Registry → Repositories)
Verify: Run az acr repository show-tags --name myacr --repository myapp and confirm your build ID tag appears.
Next in the Bootcamp
In Module 6: Pipeline Templates & Reusable Components, we'll cover template types (step, job, stage, variable), template parameters, extends templates for governance, template expressions, and building a shared template library for your organization.