Intermediate

Azure Repos and Build Automation with Pipelines

Source control with Azure Repos, YAML pipelines, pull requests, tests and Azure Artifacts.

Level: Intermediate
Estimated duration: 4–5 hours
Last updated: June 2026

Table of Contents

  1. Azure Repos – Source Control
  2. YAML Pipelines – Structure and Syntax
  3. Pull Requests and Merge Strategies
  4. Tests in Pipelines
  5. Azure Artifacts
  6. Advanced Pipeline Scenarios
  7. Complete Code Examples
  8. Comparison Tables
  9. Glossary

1. Azure Repos – Source Control

Azure Repos Overview

Azure Repos is the source control management service integrated into Azure DevOps. It supports Git (recommended) and TFVC (Team Foundation Version Control, legacy). In a modern DevOps workflow, Azure Repos serves as the source of truth for all code and configurations.

graph TD
    subgraph DEV["Developers"]
        D1[Dev 1\nVS Code + Git]
        D2[Dev 2\nVisual Studio]
        D3[Dev 3\nJetBrains]
    end
    
    subgraph REPOS["Azure Repos"]
        MAIN["main branch\n(protected)"]
        FEATURE["feature/* branches\n(short-lived)"]
        HOTFIX["hotfix/* branches\n(urgent fixes)"]
    end
    
    subgraph POLICIES["Branch Policies"]
        REVIEW["Code Review\n(min 2 reviewers)"]
        CI["Build Validation\n(CI must pass)"]
        WORKITEM["Work Items\n(traceability)"]
        COMMENTS["Comments Resolved\n(all resolved)"]
    end
    
    subgraph PIPELINE["Azure Pipelines"]
        CI_PIPE["CI Pipeline\n(build + test)"]
        CD_PIPE["CD Pipeline\n(deploy)"]
    end
    
    DEV -->|Push| FEATURE
    FEATURE -->|Pull Request| MAIN
    POLICIES -->|Gates| MAIN
    MAIN -->|Trigger| CI_PIPE
    CI_PIPE -->|Artifacts| CD_PIPE
    
    style DEV fill:#1e3a5f,color:#fff
    style REPOS fill:#7c3aed,color:#fff
    style POLICIES fill:#f97316,color:#fff
    style PIPELINE fill:#10b981,color:#fff

Configuration and Authentication

# ===== INITIAL GIT CONFIGURATION =====

# Configure Git identity
git config --global user.name "First Last"
git config --global user.email "user@company.com"

# Configure Azure Credential Manager (recommended)
# Windows: built into Git for Windows 2.x
# macOS: brew install git-credential-manager
# Linux: git credential-manager configure

# ===== CLONE A REPOSITORY =====

# Via HTTPS (Azure Credential Manager handles the token)
git clone https://dev.azure.com/{organisation}/{project}/_git/{repo}

# Via SSH (requires an SSH key configured in DevOps Settings)
git clone git@ssh.dev.azure.com:v3/{organisation}/{project}/{repo}

# ===== BASIC WORKFLOW =====

# Create a feature branch
git checkout -b feature/JIRA-123-add-user-auth main

# Develop, commit with conventional commits
git add .
git commit -m "feat: add JWT authentication middleware"
git commit -m "test: add unit tests for auth middleware"

# Push the branch
git push origin feature/JIRA-123-add-user-auth

# Create the PR (via portal or CLI)
az repos pr create \
    --repository my-repo \
    --source-branch feature/JIRA-123-add-user-auth \
    --target-branch main \
    --title "feat: add JWT authentication middleware" \
    --description "Implements JWT authentication. Resolves JIRA-123." \
    --reviewers "lead-dev@company.com" "appsec@company.com"

Branch Policies

Branch Policies protect important branches (main, develop, release/*) by enforcing controls before any merge.

flowchart TD
    PUSH[Push on\nfeature branch]
    PR[Create\nPull Request]
    
    subgraph CHECKS["Branch Policy Checks"]
        REVIEWERS["✅ Min 2 reviewers\n(4-eyes principle)"]
        CI_BUILD["✅ CI build passes\n(Build Validation)"]
        COMMENTS2["✅ Comments\nresolved"]
        WORKITEMS["✅ Work Items\nassociated"]
        CONFLICTS["✅ No\nmerge conflicts"]
    end
    
    APPROVED{All\npolicies pass?}
    
    MERGE["Merge to main\n(authorized)"]
    BLOCKED["Merge blocked\n(fix first)"]
    
    PUSH --> PR
    PR --> CHECKS
    CHECKS --> APPROVED
    APPROVED -->|Yes| MERGE
    APPROVED -->|No| BLOCKED
    BLOCKED -->|Fixes| PR
    
    style CHECKS fill:#1e3a5f,color:#fff
    style MERGE fill:#10b981,color:#fff
    style BLOCKED fill:#ef4444,color:#fff

Configure Branch Policies via Azure CLI:

# Enable build validation on the main branch
REPO_ID=$(az repos show \
    --repository my-repo \
    --project my-project \
    --query id \
    --output tsv)

# Create a minimum reviewers policy (2)
az repos policy reviewer-count create \
    --allow-downvotes false \
    --blocking true \
    --branch main \
    --enabled true \
    --minimum-approver-count 2 \
    --repository-id $REPO_ID \
    --project my-project

# Create a build validation policy
PIPELINE_ID=$(az pipelines show \
    --name "CI-my-app" \
    --project my-project \
    --query id \
    --output tsv)

az repos policy build create \
    --blocking true \
    --branch main \
    --build-definition-id $PIPELINE_ID \
    --display-name "CI Build Validation" \
    --enabled true \
    --manual-queue-only false \
    --queue-on-source-update-only true \
    --repository-id $REPO_ID \
    --valid-duration 720 \
    --project my-project

# Create a comment resolution policy
az repos policy comment-required create \
    --blocking true \
    --branch main \
    --enabled true \
    --repository-id $REPO_ID \
    --project my-project

Security and Azure Repos Permissions

# ===== ENTRA ID CONNECTION =====

# Connect Azure DevOps to Microsoft Entra ID
# (Done in Organization Settings → Azure Active Directory)

# ===== REPO PERMISSIONS =====

# View groups and permissions
az devops security permission list \
    --subject "group@company.com" \
    --namespace-id "2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87" \
    --output table

# ===== AUDIT LOGS =====

# See who did what in the repo
az devops audit stream list --project my-project

# ===== PERSONAL ACCESS TOKENS (PATs) =====
# PATs are used for:
# - Automation scripts
# - Self-hosted CI/CD agents
# - Third-party integrations

# Create a PAT via the interface:
# User Settings → Personal access tokens → New Token
# Recommended scopes (least privilege):
# - Code: Read & write (for agents)
# - Build: Read & execute (for pipelines)

2. YAML Pipelines – Structure and Syntax

YAML Pipeline Architecture

graph TD
    subgraph PIPELINE["azure-pipelines.yml"]
        TRIGGER["trigger:\nbranches, paths, tags"]
        VARS_ROOT["variables:\n(scope: entire pipeline)"]
        
        subgraph S1["Stage: Build"]
            VARS_STAGE["variables:\n(scope: stage)"]
            subgraph J1["Job: Compile"]
                VARS_JOB["variables:\n(scope: job)"]
                T1["Task: DotNetCoreCLI\nrestore"]
                T2["Task: DotNetCoreCLI\nbuild"]
                T3["Task: DotNetCoreCLI\ntest"]
                T4["Task: PublishBuildArtifacts"]
            end
        end
        
        subgraph S2["Stage: Deploy"]
            subgraph J2["Deployment Job"]
                ENV["environment: production\n(with approvals)"]
                T5["Task: AzureWebApp\ndeploy"]
            end
        end
    end
    
    S1 -->|"dependsOn (implicit)"| S2
    
    style PIPELINE fill:#1e3a5f,color:#fff
    style S1 fill:#3b82f6,color:#fff
    style S2 fill:#10b981,color:#fff

Complete YAML Pipeline: .NET Application

# azure-pipelines.yml
# Complete CI/CD pipeline for a .NET GloboTicket application

trigger:
  branches:
    include:
      - main
      - 'release/*'
      - develop
  paths:
    include:
      - 'src/**'
      - 'tests/**'
    exclude:
      - 'docs/**'
      - '*.md'

pr:
  branches:
    include:
      - main
      - develop
  paths:
    include:
      - 'src/**'
      - 'tests/**'

# ===== PIPELINE VARIABLES =====
variables:
  buildConfiguration: 'Release'
  dotNetVersion: '8.x'
  solution: '**/*.sln'
  testProjects: '**/*Tests.csproj'
  
  # Conditional variables based on branch
  ${{ if eq(variables['Build.SourceBranch'], 'refs/heads/main') }}:
    deployEnvironment: 'production'
    azureRG: 'rg-prod'
  ${{ else }}:
    deployEnvironment: 'staging'
    azureRG: 'rg-staging'

stages:
  # ============================================================
  # STAGE 1: Build and Tests
  # ============================================================
  - stage: Build
    displayName: '🔨 Build & Test'
    jobs:
      - job: BuildTest
        displayName: 'Compile, Test & Analyze'
        pool:
          vmImage: 'ubuntu-latest'
        
        steps:
          # Setup .NET
          - task: UseDotNet@2
            displayName: 'Setup .NET $(dotNetVersion)'
            inputs:
              version: '$(dotNetVersion)'
              packageType: 'sdk'
          
          # Restore packages
          - task: DotNetCoreCLI@2
            displayName: 'Restore NuGet packages'
            inputs:
              command: 'restore'
              projects: '$(solution)'
              arguments: '--locked-mode'  # Reproduce exact package versions
          
          # Build
          - task: DotNetCoreCLI@2
            displayName: 'Build solution'
            inputs:
              command: 'build'
              projects: '$(solution)'
              arguments: '--configuration $(buildConfiguration) --no-restore'
          
          # 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"
                --results-directory $(Agent.TempDirectory)
                --logger:"trx;LogFileName=test-results.trx"
                -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura
          
          # Publish test results
          - task: PublishTestResults@2
            displayName: 'Publish test results'
            inputs:
              testResultsFormat: 'VSTest'
              testResultsFiles: '**/test-results.trx'
              testRunTitle: 'Unit Tests - Build $(Build.BuildId)'
              failTaskOnFailedTests: true
            condition: always()
          
          # Publish code coverage
          - task: PublishCodeCoverageResults@2
            displayName: 'Publish code coverage'
            inputs:
              codeCoverageTool: 'Cobertura'
              summaryFileLocation: '$(Agent.TempDirectory)/**/coverage.cobertura.xml'
              reportDirectory: '$(Build.ArtifactStagingDirectory)/coverage'
            condition: always()
          
          # Publish build artifacts
          - task: DotNetCoreCLI@2
            displayName: 'Publish application'
            inputs:
              command: 'publish'
              projects: '**/GloboTicket.Web.csproj'
              arguments: '--configuration $(buildConfiguration) --output $(Build.ArtifactStagingDirectory)/app'
              publishWebProjects: true
          
          - task: PublishBuildArtifacts@1
            displayName: 'Publish build artifacts'
            inputs:
              pathToPublish: '$(Build.ArtifactStagingDirectory)'
              artifactName: 'drop'
              publishLocation: 'Container'
  
  # ============================================================
  # STAGE 2: Docker Image Build
  # ============================================================
  - stage: Docker
    displayName: '🐳 Docker Build & Push'
    dependsOn: Build
    condition: succeeded()
    jobs:
      - job: DockerBuildPush
        displayName: 'Build & Push Docker image'
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - task: AzureCLI@2
            displayName: 'Login to ACR'
            inputs:
              azureSubscription: 'AzureServiceConnection'
              scriptType: bash
              scriptLocation: inlineScript
              inlineScript: |
                az acr login --name myacr
          
          - task: Docker@2
            displayName: 'Build Docker image'
            inputs:
              command: build
              repository: 'myacr.azurecr.io/globoticket-web'
              Dockerfile: 'src/GloboTicket.Web/Dockerfile'
              tags: |
                $(Build.BuildId)
                ${{ if eq(variables['Build.SourceBranch'], 'refs/heads/main') }}:
                  latest
          
          - task: Docker@2
            displayName: 'Push to ACR'
            inputs:
              command: push
              repository: 'myacr.azurecr.io/globoticket-web'
              tags: |
                $(Build.BuildId)
  
  # ============================================================
  # STAGE 3: Deployment
  # ============================================================
  - stage: Deploy
    displayName: '🚀 Deploy to $(deployEnvironment)'
    dependsOn:
      - Build
      - Docker
    condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'))
    jobs:
      - deployment: DeployApp
        displayName: 'Deploy to $(deployEnvironment)'
        pool:
          vmImage: 'ubuntu-latest'
        environment: '$(deployEnvironment)'
        strategy:
          runOnce:
            deploy:
              steps:
                - download: current
                  artifact: 'drop'
                
                - task: AzureWebApp@1
                  displayName: 'Deploy to Azure App Service'
                  inputs:
                    azureSubscription: 'AzureServiceConnection'
                    appType: 'webApp'
                    appName: 'globoticket-web'
                    package: '$(Pipeline.Workspace)/drop/app/**/*.zip'
                    deploymentMethod: 'runFromPackage'
                
                - script: |
                    # Post-deployment health check
                    APP_URL=$(az webapp show \
                        --resource-group $(azureRG) \
                        --name globoticket-web \
                        --query "defaultHostName" \
                        --output tsv)
                    
                    echo "Testing https://$APP_URL/health"
                    sleep 30
                    
                    STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://$APP_URL/health")
                    
                    if [ "$STATUS" -ne 200 ]; then
                        echo "❌ Health check failed! HTTP $STATUS"
                        exit 1
                    fi
                    
                    echo "✅ Health check passed! HTTP $STATUS"
                  displayName: 'Post-deployment health check'
                  env:
                    AZURE_SUBSCRIPTION: 'AzureServiceConnection'

Build Agents: Microsoft-hosted vs Self-hosted

# ===== MICROSOFT-HOSTED AGENTS =====

# Ubuntu (Linux)
pool:
  vmImage: 'ubuntu-latest'     # Ubuntu 22.04

# Windows
pool:
  vmImage: 'windows-latest'    # Windows Server 2022

# macOS (for iOS/macOS builds)
pool:
  vmImage: 'macOS-latest'      # macOS 14

# ===== SELF-HOSTED AGENTS =====

# By pool name
pool:
  name: 'SelfHosted-Linux'

# By pool name with demands (requirements)
pool:
  name: 'SelfHosted-Linux'
  demands:
    - docker          # Agent must have Docker
    - node >= 20     # Node.js version 20 or higher

# Agentless (for approvals, gates)
pool: server

Install and register a self-hosted agent:

# ===== LINUX =====

# Create the agent directory
mkdir ~/azdo-agent && cd ~/azdo-agent

# Download the agent
AGENT_VERSION="3.248.0"
curl -Lo agent.tar.gz "https://vstsagentpackage.azureedge.net/agent/${AGENT_VERSION}/vsts-agent-linux-x64-${AGENT_VERSION}.tar.gz"
tar xzf agent.tar.gz

# Configure (use a PAT as authentication token)
./config.sh \
    --unattended \
    --url "https://dev.azure.com/my-organisation" \
    --auth pat \
    --token "YOUR_PAT_HERE" \
    --pool "SelfHosted-Linux" \
    --agent "$(hostname)" \
    --acceptTeeEula

# Install as Linux service
sudo ./svc.sh install
sudo ./svc.sh start
sudo ./svc.sh status

# ===== WINDOWS (PowerShell) =====

$agentVersion = "3.248.0"
$agentUrl = "https://vstsagentpackage.azureedge.net/agent/$agentVersion/vsts-agent-win-x64-$agentVersion.zip"

New-Item -ItemType Directory -Path "C:\azdo-agent" -Force
Set-Location "C:\azdo-agent"

Invoke-WebRequest -Uri $agentUrl -OutFile agent.zip
Expand-Archive -Path agent.zip -DestinationPath .

.\config.cmd `
    --unattended `
    --url "https://dev.azure.com/my-organisation" `
    --auth pat `
    --token "YOUR_PAT_HERE" `
    --pool "SelfHosted-Windows" `
    --agent $env:COMPUTERNAME `
    --acceptTeeEula

# Install as Windows service
.\svc.cmd install
.\svc.cmd start
.\svc.cmd status

Variables and Variable Groups

# ===== VARIABLE TYPES =====

# 1. Inline variables in YAML
variables:
  buildConfig: 'Release'
  imageTag: '$(Build.BuildId)'

# 2. Alternative syntax (more explicit)
variables:
  - name: buildConfig
    value: 'Release'
  - name: imageTag
    value: '$(Build.BuildId)'
  
  # Read-only variable
  - name: secretToken
    value: 'never-put-here'  # Use UI secrets instead
    readonly: true

# 3. Variable Group
variables:
  - group: 'production-secrets'  # Defined in Library

# 4. Combination
variables:
  - group: 'production-secrets'
  - name: appName
    value: 'my-app'
  - name: resourceGroup
    value: 'rg-prod'

# ===== VARIABLE SCOPES =====

variables:
  pipelineVar: 'accessible everywhere'

stages:
  - stage: Build
    variables:
      stageVar: 'accessible in Build only'
    jobs:
      - job: MyJob
        variables:
          jobVar: 'accessible in MyJob only'
        steps:
          - script: |
              echo "Pipeline: $(pipelineVar)"
              echo "Stage: $(stageVar)"
              echo "Job: $(jobVar)"

# ===== PREDEFINED VARIABLES (System variables) =====

# $(Build.BuildId)              - Unique build ID
# $(Build.BuildNumber)          - Build number (e.g. 20240115.1)
# $(Build.SourceBranch)         - Full branch name (refs/heads/main)
# $(Build.SourceBranchName)     - Short name (main)
# $(Build.Repository.Name)      - Repository name
# $(Build.ArtifactStagingDirectory) - Temporary folder for artifacts
# $(Agent.BuildDirectory)       - Agent build directory
# $(Pipeline.Workspace)         - Pipeline workspace

# ===== SECRETS VIA VARIABLE GROUPS LINKED TO KEY VAULT =====

# In Library → Variable Group:
# → "Link secrets from Azure Key Vault"
# → Select subscription and Key Vault
# → Choose secrets: DB_PASSWORD, API_KEY, etc.

# Use in pipeline:
variables:
  - group: 'kv-production-secrets'

steps:
  - script: echo "DB_PASSWORD=$(DB_PASSWORD)"  # *** in logs
    env:
      DB_PASSWORD: $(DB_PASSWORD)  # Inject secret as env variable

Complete Triggers

# ===== CI TRIGGER (push trigger) =====
trigger:
  branches:
    include:
      - main
      - 'release/v*'
      - develop
    exclude:
      - 'experimental/*'
  paths:
    include:
      - 'src/**'
      - 'tests/**'
    exclude:
      - '**/*.md'
      - 'docs/**'
  tags:
    include:
      - 'v*'  # Any tag starting with v

# Disable CI trigger
# trigger: none

# ===== PR TRIGGER =====
pr:
  branches:
    include:
      - main
      - develop
  autoCancel: true  # Cancel old PR builds if new push

# ===== SCHEDULED TRIGGER =====
schedules:
  # Nightly build Monday through Friday
  - cron: '0 2 * * 1-5'
    displayName: 'Nightly build (Mon-Fri 2AM UTC)'
    branches:
      include:
        - main
    always: true  # Even without code changes
  
  # Weekly build on Sunday
  - cron: '0 4 * * 0'
    displayName: 'Weekly security scan (Sunday 4AM UTC)'
    branches:
      include:
        - main
    always: true

# ===== PIPELINE COMPLETION TRIGGER =====
resources:
  pipelines:
    - pipeline: infrastructure-pipeline  # Local alias
      source: 'MyProject/Infrastructure'  # Source pipeline name
      trigger:
        branches:
          include:
            - main
        stages:
          - DeployStaging  # Only if this stage succeeded

3. Pull Requests and Merge Strategies

Complete Pull Request Workflow

sequenceDiagram
    participant DEV as Developer
    participant BRANCH as Feature Branch
    participant PR as Pull Request
    participant REVIEW as Code Reviewers
    participant CI as CI Pipeline
    participant MAIN as main branch

    DEV->>BRANCH: git checkout -b feature/JIRA-123
    DEV->>BRANCH: git commit (conventional commits)
    DEV->>PR: git push + create PR
    
    Note over PR: Branch Policies evaluated
    
    par CI Build
        CI->>BRANCH: Build validation
        CI-->>PR: ✅ CI passes
    and Code Review
        REVIEW->>PR: Code review
        REVIEW->>PR: Comments
        DEV->>PR: Reply/fix
        REVIEW->>PR: ✅ Approve
    end
    
    PR->>PR: ✅ All policies satisfied
    DEV->>MAIN: Squash merge
    Note over MAIN: Clean history\n1 commit per PR

Git Merge Strategies

graph LR
    subgraph BEFORE["Before merge"]
        M1[main: A---B---C]
        F1[feature: A---B---D---E]
    end
    
    subgraph SQUASH["Squash Merge"]
        M2[main: A---B---C---F]
        NOTE2["F = D+E\ncombined"]
    end
    
    subgraph REBASE["Rebase + Fast-forward"]
        M3[main: A---B---C---D'---E']
        NOTE3["D', E' = commits\nreplayed on C"]
    end
    
    subgraph NOFF["No Fast-forward (Merge commit)"]
        M4[main: A---B---C---M]
        NOTE4["M = merge commit\nD and E visible"]
    end
    
    BEFORE --> SQUASH
    BEFORE --> REBASE
    BEFORE --> NOFF
    
    style SQUASH fill:#10b981,color:#fff
    style REBASE fill:#3b82f6,color:#fff
    style NOFF fill:#f59e0b,color:#000
StrategyHistoryClarityComplexityRecommended
SquashVery clean✅ One commit per PRSimple✅ Agile teams
RebaseLinear✅ Each commit visibleModerate✅ For detailed history
Fast-forwardLinear (if possible)✅ SimpleLow✅ Small teams
No fast-forwardMerge commits⚠️ More noiseSimpleFor explicit traceability

Conventional Commits and Git Hygiene

# ===== CONVENTIONAL COMMITS =====
# Format: <type>(<scope>): <description>
# Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert

# New features
git commit -m "feat(auth): add JWT authentication middleware"
git commit -m "feat(cart): implement checkout workflow"

# Bug fixes
git commit -m "fix(api): resolve null reference in UserService"
git commit -m "fix(cart): correct total calculation for discounted items"

# Documentation
git commit -m "docs(api): update OpenAPI specification"

# Tests
git commit -m "test(auth): add unit tests for token validation"

# Configuration/infrastructure
git commit -m "chore: upgrade .NET to 8.0"
git commit -m "ci: add Trivy container scan to pipeline"

# Refactoring
git commit -m "refactor(payment): extract payment processing to service"

# Performance
git commit -m "perf(db): add index on orders.customer_id"

# BREAKING CHANGE (in the commit body)
git commit -m "feat(api)!: change authentication endpoint to /v2/auth

BREAKING CHANGE: The authentication endpoint has moved from /api/auth to /v2/auth.
Update all clients to use the new endpoint."

# ===== GITFLOW VS TRUNK-BASED =====

# GitFlow: long-lived branches
# main (production) ← develop ← feature branches
# Release branches, hotfix branches

# Trunk-Based Development (recommended for DevOps):
# Everything to main, feature flags for incomplete features
# Short pull requests (< 1 day of code)

4. Tests in Pipelines

Layered Test Strategy

pie title Test Pyramid Azure DevOps
    "E2E Tests - Playwright" : 5
    "Integration Tests" : 20
    "Unit Tests" : 75
graph LR
    subgraph UNIT["Unit Tests\n(75% - fast)"]
        U1["XUnit/.NET Tests\nxunit, NUnit, MSTest"]
        U2["Jest/Mocha Tests\n(JavaScript)"]
        U3["PyTest Tests\n(Python)"]
    end
    
    subgraph INT["Integration Tests\n(20% - moderate)"]
        I1["API Tests\nTestcontainers"]
        I2["DB Tests\nSQL Server docker"]
        I3["Service Tests"]
    end
    
    subgraph E2E["E2E Tests\n(5% - slow)"]
        E1["Browser Tests\nPlaywright"]
        E2["Load Tests\nk6, Azure Load Test"]
        E3["API Smoke Tests"]
    end
    
    CI_PIPELINE[CI Pipeline] --> UNIT
    CI_PIPELINE --> INT
    CD_PIPELINE[CD Pipeline] --> E2E
    
    style UNIT fill:#10b981,color:#fff
    style INT fill:#f59e0b,color:#000
    style E2E fill:#ef4444,color:#fff

.NET Unit Tests in Pipelines

# .NET tests with code coverage

- stage: Test
  jobs:
    - job: UnitTests
      pool:
        vmImage: 'ubuntu-latest'
      steps:
        - task: UseDotNet@2
          inputs:
            version: '8.x'
        
        - task: DotNetCoreCLI@2
          displayName: 'Restore'
          inputs:
            command: restore
            projects: '**/*.sln'
        
        - task: DotNetCoreCLI@2
          displayName: 'Build'
          inputs:
            command: build
            projects: '**/*.sln'
            arguments: '--configuration Release --no-restore'
        
        - task: DotNetCoreCLI@2
          displayName: 'Run Unit Tests'
          inputs:
            command: test
            projects: '**/*UnitTests.csproj'
            arguments: >-
              --configuration Release
              --no-build
              --collect:"XPlat Code Coverage"
              --results-directory $(Build.SourcesDirectory)/TestResults
              --logger:"trx;LogFileName=unit-tests.trx"
              -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura
        
        # Code coverage threshold (fail if < 80%)
        - task: DotNetCoreCLI@2
          displayName: 'Check Code Coverage Threshold'
          inputs:
            command: custom
            custom: tool
            arguments: run reportgenerator -reports:"$(Build.SourcesDirectory)/TestResults/**/coverage.cobertura.xml" -targetdir:"$(Build.SourcesDirectory)/TestResults/CoverageReport" -reporttypes:"TextSummary;HtmlInline_AzurePipelines"
        
        - script: |
            COVERAGE=$(cat $(Build.SourcesDirectory)/TestResults/CoverageReport/Summary.txt | grep "Line coverage:" | awk '{print $3}' | tr -d '%')
            echo "Code coverage: $COVERAGE%"
            
            if (( $(echo "$COVERAGE < 80" | bc -l) )); then
                echo "❌ Code coverage $COVERAGE% is below threshold of 80%!"
                exit 1
            fi
            echo "✅ Code coverage $COVERAGE% meets threshold of 80%"
          displayName: 'Validate Coverage Threshold'
        
        - task: PublishTestResults@2
          displayName: 'Publish Test Results'
          inputs:
            testResultsFormat: 'VSTest'
            testResultsFiles: '**/unit-tests.trx'
            testRunTitle: 'Unit Tests'
            failTaskOnFailedTests: true
          condition: always()
        
        - task: PublishCodeCoverageResults@2
          displayName: 'Publish Code Coverage'
          inputs:
            codeCoverageTool: 'Cobertura'
            summaryFileLocation: '**/coverage.cobertura.xml'
            pathToSources: '$(Build.SourcesDirectory)/src'
          condition: always()

E2E Tests with Playwright

# Playwright E2E tests in the CD pipeline

- stage: PlaywrightTests
  displayName: 'E2E Tests (Playwright)'
  dependsOn: DeployStaging
  jobs:
    - job: E2ETests
      displayName: 'Playwright E2E Tests'
      pool:
        vmImage: 'ubuntu-latest'
      steps:
        # Download test artifacts (published in CI)
        - download: current
          artifact: playwright-tests
          displayName: 'Download Playwright test artifacts'
        
        # Extract the ZIP
        - task: ExtractFiles@1
          displayName: 'Extract Playwright tests'
          inputs:
            archiveFilePatterns: '$(Pipeline.Workspace)/playwright-tests/**/*.zip'
            destinationFolder: '$(Agent.TempDirectory)/playwright'
        
        # Install Playwright browsers
        - script: |
            cd $(Agent.TempDirectory)/playwright
            npm ci
            npx playwright install chromium firefox webkit
          displayName: 'Install Playwright browsers'
        
        # Run tests
        - script: |
            cd $(Agent.TempDirectory)/playwright
            npx playwright test \
                --config=playwright.config.ts \
                --reporter=junit \
                --reporter=html \
                --output-folder=test-results
          displayName: 'Run Playwright E2E Tests'
          env:
            BASE_URL: 'https://staging.my-app.com'
            CI: 'true'
        
        # Publish results
        - task: PublishTestResults@2
          displayName: 'Publish Playwright Results'
          inputs:
            testResultsFormat: 'JUnit'
            testResultsFiles: '**/test-results.xml'
            testRunTitle: 'Playwright E2E Tests'
            failTaskOnFailedTests: true
          condition: always()
        
        # Publish HTML report (for failures)
        - task: PublishBuildArtifacts@1
          displayName: 'Publish Playwright HTML Report'
          inputs:
            PathtoPublish: '$(Agent.TempDirectory)/playwright/playwright-report'
            ArtifactName: 'playwright-html-report'
          condition: always()

5. Azure Artifacts

Azure Artifacts Architecture

Azure Artifacts is the package manager integrated into Azure DevOps. It allows creating private feeds (package registries) that serve as a secure proxy for all packages used by your teams.

graph TD
    subgraph UPSTREAM["Upstream Sources (public)"]
        NUGET[NuGet.org]
        NPM_PUB[npmjs.com]
        MAVEN[Maven Central]
        PYPI[PyPI]
    end
    
    subgraph ARTIFACTS["Azure Artifacts Feed"]
        CACHE["Package cache\n(public proxy)"]
        INTERNAL["Internal packages\n(your libraries)"]
        CURATED["Approved packages\n(whitelist)"]
    end
    
    subgraph CONSUMERS["Consumers"]
        DEV1[Dev locally\n.npmrc, nuget.config]
        PIPELINE[CI/CD Pipeline\nrestore packages]
        DOCKER[Dockerfile\nnpm install]
    end
    
    UPSTREAM -->|Cache| ARTIFACTS
    ARTIFACTS --> CONSUMERS
    
    style UPSTREAM fill:#6b7280,color:#fff
    style ARTIFACTS fill:#3b82f6,color:#fff
    style CONSUMERS fill:#10b981,color:#fff

Create and Configure a Feed

# ===== CREATE A FEED =====

# Organizational feed (shared across all projects)
az artifacts feed create \
    --name "company-feed" \
    --public false \
    --project my-project \
    --organization https://dev.azure.com/my-org

# Project feed (limited to one project)
az artifacts feed create \
    --name "project-internal-packages" \
    --project my-project \
    --scope project

# ===== CONFIGURE NuGet FOR THE FEED =====

# Create or modify nuget.config
cat > nuget.config << 'EOF'
<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <clear />
    <!-- Azure Artifacts feed as main proxy -->
    <add key="AzureArtifacts" 
         value="https://pkgs.dev.azure.com/my-org/_packaging/company-feed/nuget/v3/index.json" />
  </packageSources>
  <packageSourceCredentials>
    <AzureArtifacts>
      <add key="Username" value="PAT" />
      <!-- Token injected by the pipeline -->
      <add key="ClearTextPassword" value="%AZURE_ARTIFACTS_PAT%" />
    </AzureArtifacts>
  </packageSourceCredentials>
</configuration>
EOF

# ===== CONFIGURE NPM FOR THE FEED =====

# Create .npmrc
cat > .npmrc << 'EOF'
registry=https://pkgs.dev.azure.com/my-org/_packaging/company-feed/npm/registry/
always-auth=true
EOF

# NPM authentication with PAT
echo "//pkgs.dev.azure.com/my-org/_packaging/company-feed/npm/registry/:_authToken=${AZURE_ARTIFACTS_PAT}" >> ~/.npmrc

Publish Internal Packages

# Publish an internal NuGet package

- stage: PublishPackage
  displayName: 'Publish NuGet Package'
  condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
  jobs:
    - job: PublishNuGet
      pool:
        vmImage: 'ubuntu-latest'
      steps:
        - task: UseDotNet@2
          inputs:
            version: '8.x'
        
        # Automatic versioning with GitVersion
        - task: gitversion/setup@1
          displayName: 'Install GitVersion'
          inputs:
            versionSpec: '5.x'
        
        - task: gitversion/execute@1
          displayName: 'Determine version'
          name: gitversion
        
        # Build the NuGet package
        - task: DotNetCoreCLI@2
          displayName: 'Build NuGet package'
          inputs:
            command: pack
            projects: '**/MyLibrary.csproj'
            arguments: >-
              --configuration Release
              -p:PackageVersion=$(gitversion.SemVer)
              --output $(Build.ArtifactStagingDirectory)/packages
        
        # Publish to Azure Artifacts
        - task: DotNetCoreCLI@2
          displayName: 'Push to Azure Artifacts'
          inputs:
            command: push
            packagesToPush: '$(Build.ArtifactStagingDirectory)/packages/*.nupkg'
            nuGetFeedType: 'internal'
            publishVstsFeed: 'company-feed'
            allowPackageConflicts: false  # Fail if version already exists

6. Advanced Pipeline Scenarios

Reusable YAML Templates

# templates/build-dotnet.yml - Reusable template

parameters:
  - name: solution
    type: string
    default: '**/*.sln'
  - name: buildConfig
    type: string
    default: 'Release'
  - name: dotnetVersion
    type: string
    default: '8.x'
  - name: testProjects
    type: string
    default: '**/*Tests.csproj'
  - name: publishProject
    type: string
    default: ''
  - name: coverageThreshold
    type: number
    default: 80

steps:
  - task: UseDotNet@2
    displayName: 'Setup .NET ${{ parameters.dotnetVersion }}'
    inputs:
      version: '${{ parameters.dotnetVersion }}'
  
  - task: DotNetCoreCLI@2
    displayName: 'Restore packages'
    inputs:
      command: restore
      projects: '${{ parameters.solution }}'
  
  - task: DotNetCoreCLI@2
    displayName: 'Build'
    inputs:
      command: build
      projects: '${{ parameters.solution }}'
      arguments: '--configuration ${{ parameters.buildConfig }} --no-restore'
  
  - task: DotNetCoreCLI@2
    displayName: 'Test'
    inputs:
      command: test
      projects: '${{ parameters.testProjects }}'
      arguments: '--configuration ${{ parameters.buildConfig }} --no-build --collect:"XPlat Code Coverage"'
  
  - ${{ if ne(parameters.publishProject, '') }}:
    - task: DotNetCoreCLI@2
      displayName: 'Publish'
      inputs:
        command: publish
        projects: '${{ parameters.publishProject }}'
        arguments: '--configuration ${{ parameters.buildConfig }} --output $(Build.ArtifactStagingDirectory)/app'

---
# azure-pipelines.yml - Use the template

trigger:
  branches:
    include:
      - main

stages:
  - stage: Build
    jobs:
      - job: BuildApp
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - template: templates/build-dotnet.yml
            parameters:
              solution: 'GloboTicket.sln'
              buildConfig: 'Release'
              testProjects: '**/*UnitTests.csproj'
              publishProject: 'src/GloboTicket.Web/GloboTicket.Web.csproj'
              coverageThreshold: 80

Parallel Matrix Strategy

# Tests on multiple configurations in parallel

jobs:
  - job: IntegrationTests
    displayName: 'Integration Tests'
    strategy:
      matrix:
        SQLServer2019:
          dbVersion: 'sql2019'
          dbImage: 'mcr.microsoft.com/mssql/server:2019-latest'
        SQLServer2022:
          dbVersion: 'sql2022'
          dbImage: 'mcr.microsoft.com/mssql/server:2022-latest'
        PostgreSQL:
          dbVersion: 'postgresql'
          dbImage: 'postgres:16'
      maxParallel: 3
    
    pool:
      vmImage: 'ubuntu-latest'
    
    services:
      database:
        image: $(dbImage)
        env:
          ACCEPT_EULA: 'Y'
          SA_PASSWORD: 'Test@12345'
    
    steps:
      - script: |
          echo "Testing with $(dbVersion)"
          dotnet test \
              --configuration Release \
              -- TestRunParameters.Parameter(name=\"DatabaseType\", value=\"$(dbVersion)\")
        displayName: 'Run integration tests with $(dbVersion)'

7. Complete Code Examples

Full CD Pipeline with Playwright and AKS Deployment

# azure-pipelines-cd.yml - Full Continuous Delivery

trigger:
  branches:
    include:
      - main

resources:
  pipelines:
    - pipeline: ci-pipeline
      source: 'MyProject/CI-GloboTicket'
      trigger:
        branches:
          include:
            - main

variables:
  - group: 'production-secrets'
  - name: acrName
    value: 'myacr'
  - name: aksCluster
    value: 'aks-prod'
  - name: aksRG
    value: 'rg-prod'
  - name: namespace
    value: 'production'

stages:
  - stage: DeployStaging
    displayName: 'Deploy to Staging'
    jobs:
      - deployment: DeployToStaging
        environment: staging
        pool:
          vmImage: 'ubuntu-latest'
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureCLI@2
                  displayName: 'Deploy to AKS Staging'
                  inputs:
                    azureSubscription: 'AzureServiceConnection'
                    scriptType: bash
                    scriptLocation: inlineScript
                    inlineScript: |
                      # Get AKS credentials
                      az aks get-credentials \
                          --resource-group rg-staging \
                          --name aks-staging \
                          --overwrite-existing
                      
                      # Update the image in the deployment
                      kubectl set image deployment/globoticket \
                          globoticket=$(acrName).azurecr.io/globoticket:$(resources.pipeline.ci-pipeline.runID) \
                          -n staging
                      
                      # Wait for deployment to be ready
                      kubectl rollout status deployment/globoticket -n staging
  
  - stage: StagingTests
    displayName: 'E2E Tests on Staging'
    dependsOn: DeployStaging
    jobs:
      - job: PlaywrightTests
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - checkout: self
          - script: |
              npm ci
              npx playwright install --with-deps chromium
              npx playwright test --reporter=junit
            displayName: 'Run Playwright tests'
            env:
              BASE_URL: 'https://staging.globoticket.com'
          
          - task: PublishTestResults@2
            inputs:
              testResultsFiles: '**/test-results.xml'
              testRunTitle: 'E2E Staging Tests'
            condition: always()
  
  - stage: DeployProduction
    displayName: 'Deploy to Production'
    dependsOn: StagingTests
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      - deployment: DeployToProduction
        environment: production  # Requires approval
        pool:
          vmImage: 'ubuntu-latest'
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureCLI@2
                  displayName: 'Deploy to AKS Production'
                  inputs:
                    azureSubscription: 'AzureServiceConnection'
                    scriptType: bash
                    scriptLocation: inlineScript
                    inlineScript: |
                      az aks get-credentials \
                          --resource-group $(aksRG) \
                          --name $(aksCluster) \
                          --overwrite-existing
                      
                      # Rolling update
                      kubectl set image deployment/globoticket \
                          globoticket=$(acrName).azurecr.io/globoticket:$(resources.pipeline.ci-pipeline.runID) \
                          -n $(namespace)
                      
                      # Verify the rollout
                      kubectl rollout status deployment/globoticket -n $(namespace)
                      
                      # Health check
                      sleep 60
                      PROD_URL=$(kubectl get ingress globoticket -n $(namespace) -o jsonpath='{.spec.rules[0].host}')
                      
                      HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://$PROD_URL/health")
                      if [ "$HTTP_STATUS" -ne 200 ]; then
                          echo "❌ Health check failed after deployment! Initiating rollback..."
                          kubectl rollout undo deployment/globoticket -n $(namespace)
                          exit 1
                      fi
                      
                      echo "✅ Production deployment successful!"

8. Comparison Tables

Merge Strategies

StrategyHistoryGit ComplexityRecommended For
SquashVery clean (1 commit/PR)LowMost teams
RebaseLinear with detailModerateAdvanced Git teams
Fast-forwardLinear if possibleLowSmall teams, small PRs
Merge commitNon-linearLowExplicit traceability

Test Types in Pipelines

TypeSpeedReliabilityExecution CostPipeline Stage
Unit tests⚡ < 1 min✅ Very reliable$ Very lowCI - always
Integration tests🐌 1-5 min✅ Reliable$$ LowCI - on PR
E2E tests🐢 5-30 min⚠️ Fragile$$$ ModerateCD - staging only
Load tests🐢 30-60 min✅ Reliable$$$$ HighRelease - periodic

Azure Artifacts vs GitHub Packages vs Public Registries

CriteriaAzure ArtifactsGitHub PackagesNuGet.org / npmjs
ADO integration✅ Native✅ Via actions❌ External
Private packages❌ (paid)
Upstream proxy✅ Curated feedsN/A
Audit trail
RBAC✅ Azure AD✅ GitHub perms
PricingIncluded in ADOIncluded GitHubFree (public)

9. Glossary

TermDefinition
Azure ReposGit source control management service integrated into Azure DevOps
Branch PolicyProtection rules applied to Azure Repos branches
Build ValidationPolicy requiring CI to succeed before a PR merge
4-eyes principleRequires at least 2 approvals on a PR (compliance)
YAML PipelineCI/CD pipeline defined as YAML code in the repository
StageLogical grouping of Jobs in a YAML pipeline
JobSet of Steps running on a single agent
StepUnit of work in a Job (Task or Script)
TaskReusable automation plugin (e.g. DotNetCoreCLI@2)
Agent PoolSet of build agents available for pipelines
Microsoft-hosted AgentAgent managed by Microsoft, reset after each run
Self-hosted AgentAgent installed on a VM you manage yourself
Variable GroupGroup of variables shared across multiple pipelines
Pipeline LibraryAzure DevOps section for storing Variable Groups and Secure Files
Service ConnectionSecure connection to an external service (Azure, GitHub, etc.)
TriggerCondition triggering pipeline execution
PR TriggerTriggered when a Pull Request is created or updated
Scheduled TriggerTriggered according to a cron expression
Squash MergeStrategy combining all PR commits into one
Conventional CommitsStandard for structured commit messages (feat, fix, docs, etc.)
Azure ArtifactsPackage management service (NuGet, npm, Maven, Python)
FeedPackage registry in Azure Artifacts
Upstream SourceExternal package source proxied by Azure Artifacts
Coverage ThresholdMinimum code coverage threshold for the build to succeed
PlaywrightMicrosoft’s multi-browser E2E testing framework
GitVersionAutomatic semantic versioning tool based on Git history
EnvironmentAzure DevOps deployment target with checks and history
Deployment JobAzure Pipelines Job type linked to an Environment
TemplateReusable YAML file across multiple pipelines
Matrix StrategyRun the same Job with multiple configurations in parallel
PATPersonal Access Token - Azure DevOps authentication token

Search Terms

azure · repos · automation · pipelines · devops · iac · microsoft · pipeline · yaml · artifacts · merge · strategies · tests · .net · architecture · git · packages · playwright · pull · strategy · test

Interested in this course?

Contact us to book it or get a custom training plan for your team.