# Automation, CI/CD & GitOps — Part 8: Azure DevOps

> **Series:** Automation, CI/CD & GitOps (8 of 8)
> **Part 1:** `01-cicd-fundamentals.md` — CI/CD Fundamentals
> **Part 2:** `02-infrastructure-as-code.md` — Infrastructure as Code
> **Part 3:** `03-gitops.md` — GitOps
> **Part 4:** `04-github-actions.md` — GitHub & GitHub Actions
> **Part 5:** `05-github-security-governance.md` — GitHub Security & Governance
> **Part 6:** `06-gitlab-cicd.md` — GitLab & GitLab CI/CD
> **Part 7:** `07-bitbucket-pipelines.md` — Bitbucket & Bitbucket Pipelines
> **Part 8:** This file — Azure DevOps
> **Questions:** `questions.md`

## Table of Contents

1. [Where Azure DevOps Fits — Microsoft's Enterprise Play](#where-azure-devops-fits--microsofts-enterprise-play)
2. [Azure DevOps Pricing at a Glance](#azure-devops-pricing-at-a-glance)
3. [The Five Services — Boards, Repos, Pipelines, Artifacts, Test Plans](#the-five-services--boards-repos-pipelines-artifacts-test-plans)
4. [YAML Pipelines vs. Classic Pipelines](#yaml-pipelines-vs-classic-pipelines)
5. [Anatomy of `azure-pipelines.yml`](#anatomy-of-azure-pipelinesyml)
6. [Stages, Jobs, and Steps — Three Layers of Nesting](#stages-jobs-and-steps--three-layers-of-nesting)
7. [A Minimal Pipeline, Built Up Step by Step](#a-minimal-pipeline-built-up-step-by-step)
8. [Multi-Stage Pipelines and `dependsOn`](#multi-stage-pipelines-and-dependson)
9. [Matrix Builds — `strategy: matrix`](#matrix-builds--strategy-matrix)
10. [Artifacts and Caching](#artifacts-and-caching)
11. [Templates — Azure's Reusability Model](#templates--azures-reusability-model)
12. [Pipeline Decorators — Organization-Wide Injected Steps](#pipeline-decorators--organization-wide-injected-steps)
13. [Service Connections and Workload Identity Federation](#service-connections-and-workload-identity-federation)
14. [A Full Worked Example: Deploying to Azure via Workload Identity Federation](#a-full-worked-example-deploying-to-azure-via-workload-identity-federation)
15. [Variables and Variable Groups](#variables-and-variable-groups)
16. [Environments, Approvals, and Checks](#environments-approvals-and-checks)
17. [Deployment Strategies — `runOnce`, `rolling`, and `canary`](#deployment-strategies--runonce-rolling-and-canary)
18. [Branch Policies — Azure Repos](#branch-policies--azure-repos)
19. [Agent Pools — Microsoft-Hosted and Self-Hosted](#agent-pools--microsoft-hosted-and-self-hosted)
20. [A Full Realistic Multi-Stage Pipeline](#a-full-realistic-multi-stage-pipeline)
21. [Four-Way Comparison — GitHub, GitLab, Bitbucket, and Azure DevOps](#four-way-comparison--github-gitlab-bitbucket-and-azure-devops)
22. [Common Mistakes](#common-mistakes)
23. [Worked Practice Problems](#worked-practice-problems)
24. [Summary and What's Next](#summary-and-whats-next)

---

## Where Azure DevOps Fits — Microsoft's Enterprise Play

Azure DevOps closes out this series' platform tour with a fourth distinct positioning, worth naming as precisely as the other three were: **Azure DevOps is Microsoft's enterprise project-and-software-delivery suite**, historically evolved from Team Foundation Server (TFS) — a product with roots going back to enterprise .NET shops long before "DevOps platform" was a marketing category anyone used. It bundles project planning (Boards), source control (Repos — which can host Git or, still in real legacy use, the older centralized TFVC), CI/CD (Pipelines), package management (Artifacts), and manual/exploratory test management (Test Plans) as five separate but integrated services under one umbrella.

```mermaid
graph TD
    Github["GITHUB:<br/>git host + CI/CD"]
    Gitlab["GITLAB:<br/>fully unified DevOps<br/>platform"]
    Bitbucket["BITBUCKET:<br/>git host + CI/CD,<br/>wired into Atlassian suite"]
    Azure["AZURE DEVOPS:<br/>five SEPARATE but<br/>integrated services<br/>(Boards/Repos/Pipelines/<br/>Artifacts/Test Plans),<br/>deep roots in enterprise<br/>.NET/Windows shops,<br/>works with ANY git repo,<br/>not just Azure Repos"]
```

**One structural fact distinguishes Azure DevOps from every platform covered so far, and it's genuinely important:** Azure Pipelines can build and deploy from **any** Git repository — Azure Repos, GitHub, GitLab, Bitbucket, or a generic Git server — not only from Azure DevOps's own repo hosting. This means "using Azure DevOps" and "using Azure Repos" are two independent decisions; a team can keep code on GitHub entirely and use only Azure Pipelines for CI/CD, something none of GitHub Actions, GitLab CI/CD, or Bitbucket Pipelines support in reverse (each of those is tied to its own platform's repos). This chapter covers Azure Pipelines primarily, with Azure Repos and Boards covered only where they intersect with pipeline mechanics (branch policies, work-item linking) — mirroring this course's build-first-then-govern structure from the GitHub chapters, condensed into one chapter as with GitLab and Bitbucket.

Keep that cross-git-host capability in mind while reading — several sections ahead return to it directly, since it changes what "adopting Azure DevOps" even means as a decision compared to the other three platforms in this series, each of which bundles CI/CD and repo hosting as one inseparable choice.



---

## Azure DevOps Pricing at a Glance

| Tier | Included parallel jobs / minutes | Typical fit |
|---|---|---|
| **Free (Basic)** | 1 free Microsoft-hosted parallel job (1,800 minutes/month), unlimited self-hosted parallel jobs | Small teams, open-source (public projects get more free Microsoft-hosted parallelism) |
| **Basic + Test Plans** | Same Pipelines allowance, adds manual/exploratory test management | Teams needing formal QA test-case tracking |
| **Paid parallel jobs** | Purchased per additional concurrent Microsoft-hosted pipeline | Larger teams running many pipelines concurrently |

**The billing unit here is meaningfully different from the "included minutes, billed per-minute past that" model of the other three platforms** — Azure Pipelines bills primarily by **parallel job slots** (how many pipelines can run *concurrently*), each included slot then carrying its own separate monthly minute allowance, rather than one shared pool of minutes. A team with a single parallel job slot and 20 pipelines queued at once runs them one at a time regardless of how many total minutes remain in the month — a genuinely different scaling bottleneck to plan around than GitHub's or GitLab's shared-minute-pool model, and one worth understanding explicitly before assuming "we have plenty of minutes left" means "our pipelines aren't queuing."

---

## The Five Services — Boards, Repos, Pipelines, Artifacts, Test Plans

| Service | What it is | Rough equivalent elsewhere |
|---|---|---|
| **Boards** | Work item tracking (epics, features, user stories, bugs), Kanban/Scrum boards | Jira (Bitbucket's ecosystem), GitHub Issues/Projects, GitLab Issues |
| **Repos** | Git (or legacy TFVC) source control | GitHub/GitLab/Bitbucket repo hosting |
| **Pipelines** | CI/CD — the focus of this chapter | GitHub Actions, GitLab CI/CD, Bitbucket Pipelines |
| **Artifacts** | Package feeds (NuGet, npm, Maven, Python) | GitHub Packages, GitLab Package Registry |
| **Test Plans** | Manual and exploratory test case management | No direct equivalent in the other three platforms covered in this series |

**Test Plans is worth calling out as genuinely distinctive** — none of GitHub, GitLab, or Bitbucket ship a first-party manual/exploratory test-case management tool as part of their core product; this is commonly handled by a separate third-party tool (TestRail, Zephyr) alongside any of the other three platforms. For an enterprise QA organization still running significant manual/exploratory testing alongside automated pipelines, this native integration is a genuine differentiator, similar in spirit to how Bitbucket's Jira integration (Part 7) is its own standout strength.

---

## YAML Pipelines vs. Classic Pipelines

Azure DevOps carries a real, still-relevant legacy split worth understanding precisely, since a lot of existing enterprise Azure DevOps usage predates YAML pipelines entirely:

| | **Classic Pipelines** | **YAML Pipelines** |
|---|---|---|
| **Defined as** | A visual, drag-and-drop designer, stored as UI configuration | A `azure-pipelines.yml` file, checked into the repo alongside the code |
| **Version-controlled with the code?** | No — lives entirely in Azure DevOps's own database | Yes — reviewed in the same PR as the code it builds |
| **Reusability** | Task groups (UI-configured, less portable) | Templates (versioned, file-based) |
| **Current Microsoft guidance** | Legacy — still supported, still genuinely common in older enterprise setups | **The recommended approach for any new pipeline** |

```mermaid
graph LR
    Classic["CLASSIC:<br/>Visual designer,<br/>config lives in the<br/>Azure DevOps UI/DB,<br/>NOT in the repo"] --> ClassicNote["Pipeline changes are<br/>invisible to code review —<br/>the exact problem CI/CD-<br/>as-code (Part 1's premise)<br/>exists to solve"]

    Yaml["YAML PIPELINES:<br/>azure-pipelines.yml,<br/>checked into the repo"] --> YamlNote["Pipeline changes reviewed<br/>in the SAME PR as the<br/>code — matches every other<br/>platform in this series"]
```

**This chapter covers YAML Pipelines exclusively**, both because it's Microsoft's own current recommendation and because it's the only one of the two models that fits the "pipeline as code" premise this entire series has been built on since Part 1 — a Classic pipeline, being pure UI configuration outside version control, is a genuine step backward against every principle established in this course about reviewable, auditable infrastructure and pipeline changes (directly echoing Part 2's IaC argument against manual, undocumented console changes). A team still running Classic pipelines in production should treat migrating to YAML as real, prioritizable technical debt, not a cosmetic preference.

---

## Anatomy of `azure-pipelines.yml`

Azure Pipelines has the deepest structural nesting of any platform covered in this series — four layers instead of GitHub's three (Workflow → Jobs → Steps) or GitLab/Bitbucket's two (Pipeline → Jobs/Steps):

```mermaid
graph TD
    Pipeline["PIPELINE<br/>(azure-pipelines.yml)"] --> Trigger["trigger: — what<br/>Git events run it"]
    Pipeline --> Stages["stages: — ordered,<br/>can depend on each<br/>other via dependsOn"]
    Stages --> Stage1["stage: Build"]
    Stages --> Stage2["stage: Deploy"]
    Stage1 --> Jobs1["jobs: — one or<br/>more per stage"]
    Jobs1 --> Job1["job: BuildJob"]
    Job1 --> Steps1["steps: — tasks or<br/>scripts, run in order"]
```

- **Pipeline** — the whole file, plus a `trigger:` (push events) and `pr:` (pull request events) at the top.
- **Stage** — the highest-level grouping (`Build`, `Test`, `Deploy`), each running on its own agent allocation, ordered via `dependsOn` (covered shortly). A single-stage pipeline can omit `stages:` entirely and just declare `jobs:` directly at the top level — the extra nesting layer is optional for simple pipelines.
- **Job** — a unit of work assigned to one agent, analogous to a GitHub/GitLab job.
- **Step** — either a `script:` (raw shell) or a `task:` (a pre-built, versioned unit of automation, e.g. `AzureCLI@2`) — Azure's version of GitHub's Action / GitLab's Pipe, covered in the Templates section.

```yaml
trigger:
  branches:
    include: [main]

stages:
  - stage: Build
    jobs:
      - job: BuildJob
        pool:
          vmImage: ubuntu-latest
        steps:
          - script: npm ci
          - script: npm run build
```

---

## Stages, Jobs, and Steps — Three Layers of Nesting

The four-layer model exists specifically to let a single pipeline express genuinely large, multi-team, multi-environment delivery processes without external tooling — a `Build` stage feeding into parallel `DeployStaging` and `DeployCanary` stages, each containing multiple jobs, is entirely native:

```yaml
stages:
  - stage: Build
    jobs:
      - job: Compile
        pool: { vmImage: ubuntu-latest }
        steps:
          - script: npm ci && npm run build

  - stage: Test
    dependsOn: Build
    jobs:
      - job: UnitTests
        pool: { vmImage: ubuntu-latest }
        steps:
          - script: npm test
      - job: LintCheck
        pool: { vmImage: ubuntu-latest }
        steps:
          - script: npm run lint

  - stage: Deploy
    dependsOn: Test
    jobs:
      - deployment: DeployProd
        environment: production
        strategy:
          runOnce:
            deploy:
              steps:
                - script: ./deploy.sh
```

**Notice the `Test` stage's two jobs (`UnitTests`, `LintCheck`) run in parallel by default** (same job-parallelism default as GitHub and GitLab-within-a-stage), while stages themselves are sequential unless a `dependsOn` graph says otherwise — structurally the closest of the four platforms to GitLab's stage-sequential-by-default model, though Azure's `dependsOn` operates at the *stage* level primarily, with job-level `dependsOn` an additional, less commonly needed refinement within a stage.

---

## A Minimal Pipeline, Built Up Step by Step

**Step 1 — bare minimum, no stages at all (the optional top layer, omitted for simplicity):**
```yaml
pool:
  vmImage: ubuntu-latest
steps:
  - script: echo "hello"
```

**Step 2 — a real Node project:**
```yaml
trigger:
  branches: { include: [main] }
pool:
  vmImage: ubuntu-latest
steps:
  - task: NodeTool@0
    inputs: { versionSpec: '20.x' }
  - script: npm ci
  - script: npm test
```

**Step 3 — introducing explicit jobs, so build and test can eventually run on different pools/images:**
```yaml
trigger:
  branches: { include: [main] }
jobs:
  - job: Build
    pool: { vmImage: ubuntu-latest }
    steps:
      - task: NodeTool@0
        inputs: { versionSpec: '20.x' }
      - script: npm ci
      - script: npm run build
  - job: Test
    pool: { vmImage: ubuntu-latest }
    steps:
      - task: NodeTool@0
        inputs: { versionSpec: '20.x' }
      - script: npm ci
      - script: npm test
```

**Step 4 — promoting to full stages, adding a PR trigger and a manual-gated deploy:**
```yaml
trigger:
  branches: { include: [main] }
pr:
  branches: { include: [main] }

stages:
  - stage: Build
    jobs:
      - job: BuildAndTest
        pool: { vmImage: ubuntu-latest }
        steps:
          - task: NodeTool@0
            inputs: { versionSpec: '20.x' }
          - script: npm ci
          - script: npm run build
          - script: npm test

  - stage: Deploy
    dependsOn: Build
    condition: succeeded()
    jobs:
      - deployment: DeployProd
        environment: production        # approval gate configured here, covered later in this chapter
        pool: { vmImage: ubuntu-latest }
        strategy:
          runOnce:
            deploy:
              steps:
                - script: ./deploy.sh
```

**A `task:` (like `NodeTool@0` above) is the third distinct kind of step, alongside `script:` and (covered next) `template:`** — a versioned, parameterized, pre-built unit shipped either by Microsoft or a third party via the Visual Studio Marketplace, closest in spirit to a GitHub Action but, like Bitbucket's Pipes, generally narrower in scope than a full reusable job.

---

## Multi-Stage Pipelines and `dependsOn`

`dependsOn` at the stage level is Azure's DAG-building mechanism — the same underlying need already covered for GitHub's job-level `needs:` (Part 4) and GitLab's job-level `needs:` (Part 6), applied one layer up, at stage granularity:

```yaml
stages:
  - stage: BuildFrontend
    jobs: [...]
  - stage: BuildBackend
    jobs: [...]
  - stage: DeployStaging
    dependsOn:
      - BuildFrontend
      - BuildBackend
    jobs: [...]
  - stage: DeployProdCanary
    dependsOn: DeployStaging
    jobs: [...]
  - stage: DeployProdFull
    dependsOn: DeployProdCanary
    condition: succeeded()
    jobs: [...]
```

```mermaid
flowchart LR
    BF["BuildFrontend"] --> DS["DeployStaging"]
    BB["BuildBackend"] --> DS
    DS --> Canary["DeployProdCanary"]
    Canary --> Full["DeployProdFull"]
    BF -.parallel with.-> BB
```

**`condition:` is worth its own callout — it's Azure's `if:`/`rules:` equivalent, and its default value is easy to get wrong.** By default, a stage's implicit condition is `succeeded()` — it only runs if every stage it `dependsOn` succeeded. Explicitly writing `condition: succeeded()` (as shown for `DeployProdFull` above) is redundant with the default but is common practice for clarity; the more consequential use of `condition:` is overriding that default — e.g. `condition: failed()` for a rollback/cleanup stage that should run *only* when something upstream broke, or `condition: always()` for a notification stage that should run regardless of outcome, directly mirroring the `if: failure()` / `if: always()` semantics already covered for GitHub Actions in Part 4.

---

## Matrix Builds — `strategy: matrix`

```yaml
jobs:
  - job: Test
    strategy:
      matrix:
        Node18Linux:
          nodeVersion: '18.x'
          imageName: 'ubuntu-latest'
        Node20Linux:
          nodeVersion: '20.x'
          imageName: 'ubuntu-latest'
        Node20Windows:
          nodeVersion: '20.x'
          imageName: 'windows-latest'
      maxParallel: 3
    pool:
      vmImage: $(imageName)
    steps:
      - task: NodeTool@0
        inputs: { versionSpec: $(nodeVersion) }
      - script: npm ci
      - script: npm test
```

**Two details distinguish Azure's matrix syntax from GitHub's and GitLab's, worth flagging precisely:** first, each matrix entry is an explicitly **named** key (`Node18Linux`, not an auto-generated combination label) — giving direct control over how each leg is labeled in the pipeline UI, rather than an auto-derived name from the combination values. Second, `maxParallel:` caps how many matrix legs run *concurrently*, independent of how many total legs are defined — directly relevant given this chapter's earlier point about Azure's parallel-job-slot billing model: a matrix with 12 legs but only 1 available parallel job slot runs strictly one at a time regardless of `maxParallel:`'s value, since the actual concurrency ceiling is set by the account's purchased parallel jobs, not by the matrix definition itself.

---

## Artifacts and Caching

The same cache-for-speed, artifact-for-data-passing split covered for all three prior platforms:

```yaml
steps:
  - task: Cache@2
    inputs:
      key: 'npm | "$(Agent.OS)" | package-lock.json'
      restoreKeys: 'npm | "$(Agent.OS)"'
      path: '$(Pipeline.Workspace)/.npm'
  - script: npm ci
  - script: npm run build
  - task: PublishPipelineArtifact@1
    inputs:
      targetPath: 'dist'
      artifact: 'dist'
```

Consuming the artifact in a later stage:
```yaml
  - stage: Deploy
    dependsOn: Build
    jobs:
      - job: DeployJob
        steps:
          - task: DownloadPipelineArtifact@2
            inputs:
              artifact: 'dist'
              path: '$(Pipeline.Workspace)/dist'
          - script: ./deploy.sh $(Pipeline.Workspace)/dist
```

**Unlike GitLab's automatic artifact propagation (Part 6) or Bitbucket's automatic next-step propagation (Part 7), Azure requires an explicit `DownloadPipelineArtifact@2` step** — structurally closer to GitHub Actions' explicit `actions/download-artifact` model (Part 4) than to its two more automatic peers. Both `PublishPipelineArtifact@1` and `Cache@2` are themselves `task:`s, not special first-class YAML keywords — a consistent pattern throughout Azure Pipelines where a huge amount of functionality (not just third-party integrations) is delivered as versioned, parameterized tasks rather than baked directly into the YAML schema.

---

## Templates — Azure's Reusability Model

Azure's reusability primitive, **templates**, is genuinely the most flexible of the four platforms' equivalent mechanisms — a single `template:` keyword can reference and extend YAML at any of the four structural layers (steps, jobs, stages, or the entire pipeline), not just one fixed granularity:

```yaml
# templates/build-steps.yml — a reusable STEP template
parameters:
  - name: nodeVersion
    type: string
    default: '20.x'

steps:
  - task: NodeTool@0
    inputs: { versionSpec: ${{ parameters.nodeVersion }} }
  - script: npm ci
  - script: npm run build
```

```yaml
# azure-pipelines.yml — consuming it
jobs:
  - job: Build
    pool: { vmImage: ubuntu-latest }
    steps:
      - template: templates/build-steps.yml
        parameters:
          nodeVersion: '18.x'
```

A **full pipeline template** — Azure's closest equivalent to a GitHub reusable workflow or GitLab CI/CD Component — uses `extends:`, which is structurally different from `template:` in an important way: `extends:` lets the *template itself* enforce structure the calling pipeline cannot override (e.g. mandating a security-scan stage always runs), rather than the calling file simply pulling in optional pieces:

```yaml
# templates/secure-pipeline.yml — an ENFORCING template
parameters:
  - name: buildSteps
    type: stepList
    default: []

stages:
  - stage: Build
    jobs:
      - job: BuildJob
        steps:
          - ${{ parameters.buildSteps }}
  - stage: MandatorySecurityScan          # every consuming pipeline gets this, no way to skip it
    jobs:
      - job: SASTScan
        steps:
          - script: run-sast-scanner.sh
```

```yaml
# azure-pipelines.yml — consuming via extends, NOT template:
extends:
  template: templates/secure-pipeline.yml
  parameters:
    buildSteps:
      - script: npm ci
      - script: npm run build
```

| | `template:` (steps/jobs/stages) | `extends:` (whole pipeline) |
|---|---|---|
| **Granularity** | Any one layer — reusable piece, composed into a larger pipeline you still control | The entire pipeline's structure |
| **Can the caller override/skip parts?** | Yes — it's opt-in composition | **No** — the template controls the overall shape; the caller only fills in designated parameter slots |
| **Rough analogy** | GitHub composite action / GitLab `include` | GitLab's compliance pipelines (Part 6) — centrally enforced, non-bypassable structure |

**This `extends:`-based enforcement is a genuine, distinctive strength worth comparing directly against GitLab's compliance pipelines from Part 6** — both solve the same "a specific control must run, and individual project maintainers must not be able to quietly remove it" governance problem this series has now returned to three times, using different mechanisms: GitLab injects centrally at the group/framework level entirely outside the project's own file; Azure achieves the same non-bypassable guarantee via the calling pipeline's own file structurally being unable to define stages the `extends:` template doesn't expose a parameter for.

---

## Pipeline Decorators — Organization-Wide Injected Steps

Worth a dedicated mention as the single most powerful (and least commonly known) governance mechanism covered anywhere in this series: a **pipeline decorator** is an extension, installed at the Azure DevOps organization level, that automatically injects steps into **every single pipeline in the entire organization** — with no `extends:` reference, no `include:`, and no action required from any individual pipeline author at all.

```mermaid
graph TD
    Decorator["Organization-level<br/>pipeline decorator<br/>installed ONCE"] --> P1["Pipeline A<br/>(never references<br/>the decorator)"]
    Decorator --> P2["Pipeline B<br/>(never references<br/>the decorator)"]
    Decorator --> P3["Every future pipeline<br/>created in this org,<br/>automatically"]
    P1 --> Injected1["Decorator's steps<br/>injected before/after<br/>every job automatically"]
```

A decorator is authored as a small Azure DevOps extension (published to the organization privately, not necessarily the public Marketplace) and, once installed, applies retroactively to every existing pipeline and automatically to every pipeline created afterward — genuinely zero YAML changes required anywhere. A common real-world use: injecting a mandatory secret-scanning step before every single job runs, organization-wide, closing even the gap `extends:` still has (a pipeline must at least reference the enforcing template) — a decorator requires no reference at all.

**Comparing all three centralized-governance mechanisms encountered across this series side by side clarifies exactly where each sits on a spectrum from "opt-in" to "structurally required" to "organization-wide and unavoidable":**

| Mechanism | Platform | Requires the pipeline author to do anything? |
|---|---|---|
| CI/CD Component / `include:` | GitLab | Yes — must explicitly `include:` it |
| Compliance pipeline | GitLab | No — injected via a compliance framework label on the project |
| `extends:` template | Azure DevOps | Yes — the pipeline file must use `extends:` instead of plain `stages:` |
| **Pipeline decorator** | **Azure DevOps** | **No — organization-wide, fully automatic, zero pipeline-level reference** |

A decorator is the strongest guarantee of the four, precisely because it requires no cooperation whatsoever from any individual pipeline's author — it's worth reaching for specifically when a control is genuinely non-negotiable organization-wide (a mandatory secret scan, a mandatory license-compliance check) rather than something that should vary project-by-project, where `extends:` templates or compliance-framework labeling (allowing different frameworks for different project types) remain the more appropriate, more granular tool.

---

## Service Connections and Workload Identity Federation

A **Service Connection** is Azure DevOps's stored authorization to an external system (an Azure subscription, AWS, GCP, a container registry, another Azure DevOps project) — referenced by name from a pipeline, rather than the pipeline holding raw credentials directly.

```mermaid
graph TD
    Pipeline["Pipeline step:<br/>AzureCLI@2 task,<br/>references service<br/>connection by NAME"] --> SC["Service Connection<br/>(configured once, in<br/>project settings)"]
    SC --> Auth{"Authentication method"}
    Auth -->|Legacy| SP["Service Principal +<br/>stored client secret<br/>(long-lived, avoid<br/>for new setups)"]
    Auth -->|Current best practice| WIF["Workload Identity<br/>Federation — short-<br/>lived, OIDC-based,<br/>no stored secret"]
```

**Workload Identity Federation is Azure's name for the exact same OIDC pattern already covered twice in this series** (GitHub's OIDC in Part 5, GitLab's ID tokens in Part 6) — a Service Connection configured with Workload Identity Federation has Azure DevOps present a short-lived, signed token to Microsoft Entra ID (Azure AD) at pipeline run time, exchanged for temporary Azure credentials, with **no client secret stored in Azure DevOps at all**. Microsoft's own current guidance explicitly recommends Workload Identity Federation over the older Service Principal + stored secret approach for exactly the reason this series has established repeatedly: a stored secret is exploitable indefinitely until manually rotated, while a federated identity issues nothing that outlives a single pipeline run.

---

## A Full Worked Example: Deploying to Azure via Workload Identity Federation

**Step 1 — one-time setup** (via Azure Portal or Azure CLI, conceptually mirroring the IAM trust-policy setup from every prior platform's OIDC example): create a Service Connection of type "Azure Resource Manager," authentication method "Workload Identity federation (automatic)" — Azure DevOps and Azure AD handle the federated credential trust relationship between themselves automatically when this option is chosen, a meaningfully lower-friction setup step than manually authoring a JSON trust policy the way GitHub's and GitLab's AWS examples required.

**Step 2 — the pipeline**, referencing the Service Connection by name — notably, **no `permissions:` or `id_tokens:` block is needed in the YAML at all**, since the OIDC token exchange is handled entirely by the `AzureCLI@2` task itself once it's told which Service Connection to use:

```yaml
stages:
  - stage: Deploy
    jobs:
      - deployment: DeployToAzure
        environment: production
        pool: { vmImage: ubuntu-latest }
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureCLI@2
                  inputs:
                    azureSubscription: 'my-workload-identity-connection'   # references the Service Connection by name
                    scriptType: bash
                    scriptLocation: inlineScript
                    inlineScript: |
                      az storage blob upload-batch \
                        --account-name myproductionstorage \
                        --destination '$web' \
                        --source dist/
```

**This is the most ergonomically simple of the four platforms' OIDC worked examples in this series, and it's worth being explicit about why:** GitHub and GitLab both require the pipeline author to understand and correctly configure the raw OIDC mechanics (a `permissions:`/`id_tokens:` declaration, and a manually-authored cloud-side trust policy referencing specific claims). Azure DevOps's Service Connection abstraction absorbs nearly all of that complexity into a one-time, portal-driven setup step — the pipeline YAML itself looks almost identical to how it would look with a legacy secret-based Service Connection, with the actual credential-security improvement happening entirely "behind" the Service Connection's name. This is a genuine ergonomic win, at the tradeoff of the underlying mechanism being somewhat less visible/self-documenting directly in the YAML compared to GitHub's or GitLab's more explicit, in-file OIDC declarations.

---

## Variables and Variable Groups

| Scope | Configured | Typical use |
|---|---|---|
| **Pipeline variables** | Inline in the YAML, or via UI | Non-sensitive, pipeline-specific values |
| **Variable Groups** | Project Settings → Pipelines → Library | Shared across multiple pipelines; can be linked to Azure Key Vault |
| **Secret variables** | Marked "Keep this value secret" (a checkbox) in a Variable Group or pipeline settings | Credentials, tokens |

```yaml
variables:
  - group: 'shared-prod-config'    # a Variable Group, defined once in the Library, reused across pipelines
  - name: buildConfiguration
    value: 'Release'

steps:
  - script: echo "Deploying with $(dbConnectionString)"    # pulled from the linked Variable Group
```

**Variable Groups linked directly to Azure Key Vault are worth a specific callout, since they represent a meaningfully different secrets architecture than any prior platform's native secret store.** Rather than Azure DevOps itself being the system of record for a secret's value, a Key-Vault-linked Variable Group makes Azure DevOps a *pass-through* — the actual secret value lives and is rotated in Key Vault, and the pipeline fetches the current value at run time. This is architecturally the same "don't make the CI/CD platform the source of truth for a secret it doesn't need to own" principle GitLab's external-secrets integration (Part 6) applies, and directly parallels this course's GitOps chapter's External Secrets Operator pattern (Part 3) — the same idea (a reference lives in the tracked config; the real value lives in a dedicated secrets system) recurring in a third distinct context across this series.

---

## Environments, Approvals, and Checks

Azure's implementation of the manual-approval-gate concept this series has now covered four times, with genuinely the richest set of gate *types* among the four platforms:

```yaml
- stage: DeployProd
  jobs:
    - deployment: DeployProd
      environment: production        # approvals/checks configured on this Environment, NOT in YAML
      strategy:
        runOnce:
          deploy:
            steps:
              - script: ./deploy.sh
```

Configured on the `production` Environment itself (Pipelines → Environments → production → Approvals and checks), not in the YAML file — several distinct check types, usable individually or layered together:

| Check type | What it does |
|---|---|
| **Approvals** | Named users/groups must manually approve — the direct equivalent of GitHub required reviewers / GitLab protected-environment approval rules |
| **Business hours** | Deployment only permitted within a configured time window — no direct equivalent in the other three platforms covered in this series |
| **Invoke Azure Function / REST API** | An external system must return a success response before the deployment proceeds — e.g. a custom "is the on-call engineer currently paged on something else?" check |
| **Branch control** | Restricts which branches can deploy to this environment, similar to GitHub's deployment-branch-rules |
| **Required template** | Enforces that the pipeline deploying to this environment must extend a specific governance template (directly composing with the `extends:` mechanism from earlier in this chapter) |

**The "Invoke REST API" check type deserves particular attention as a genuinely distinctive capability** — it turns the deployment gate into an arbitrary, extensible programmable check rather than a fixed menu of built-in options, letting a platform team wire in literally any internal system's own "is it safe to deploy right now" logic (a feature-flag service, an incident-status API, a change-freeze calendar) as a hard gate, without needing Azure DevOps itself to natively understand that system. This composes naturally with this course's Incident Management series' concept of a deployment freeze during an active incident — a genuinely automatable version of that policy, rather than a purely process-and-trust convention.

---

## Deployment Strategies — `runOnce`, `rolling`, and `canary`

Every deployment job example so far in this chapter used `strategy: runOnce:` — deploy everything, once, in one pass. Azure Pipelines natively supports two more deployment strategies as first-class `strategy:` options, directly implementing the rolling and canary deployment patterns this series first covered tool-agnostically in Part 1 — worth returning to that original diagram now that there's a concrete platform mechanism for it.

```yaml
- deployment: DeployProd
  environment: production
  strategy:
    rolling:
      maxParallel: 20%              # replace instances in batches of 20% at a time
      preDeploy:
        steps:
          - script: ./drain-traffic.sh
      deploy:
        steps:
          - script: ./deploy-new-version.sh
      routeTraffic:
        steps:
          - script: ./enable-traffic.sh
      postRouteTraffic:
        steps:
          - script: ./run-health-check.sh
      on:
        failure:
          steps:
            - script: ./rollback.sh
        success:
          steps:
            - script: ./notify-success.sh
```

```yaml
- deployment: DeployProdCanary
  environment: production
  strategy:
    canary:
      increments: [10, 25, 50, 100]   # % of the fleet, in successive waves
      deploy:
        steps:
          - script: ./deploy-canary-slice.sh
      postRouteTraffic:
        steps:
          - script: ./check-canary-health.sh
      on:
        failure:
          steps:
            - script: ./rollback-canary.sh
```

**Each named phase (`preDeploy`, `deploy`, `routeTraffic`, `postRouteTraffic`) maps directly onto the generic deployment-strategy lifecycle this series established in Part 1**, and the `on: failure:` / `on: success:` blocks are Azure's built-in, structural equivalent of the automated-rollback discipline this course's Incident Management series argues for — expressed here as a first-class pipeline construct rather than something a team has to hand-roll with conditional steps. **None of GitHub Actions, GitLab CI/CD, or Bitbucket Pipelines ship an equivalent first-class `rolling`/`canary` deployment-strategy primitive** — on those three platforms, implementing a genuine canary or rolling rollout inside the pipeline itself (as opposed to delegating it entirely to the underlying deployment target, e.g. a Kubernetes rolling update the pipeline merely triggers) requires hand-authoring the wave/health-check/rollback logic as ordinary script steps. This is a second genuinely distinctive Azure DevOps capability, alongside cross-git-host CI/CD, worth weighing directly if a team's deployment strategy is a first-order platform-selection criterion.

---

## Branch Policies — Azure Repos

For teams using Azure Repos specifically (recall: Azure Pipelines works with any git host, but branch *policies* specifically are an Azure Repos feature), the branch-protection-equivalent controls:

- **Require a minimum number of reviewers**, with an option requiring **at least one reviewer who did not also author the change** — a subtly stronger check than a bare reviewer count, since it explicitly prevents self-approval in edge cases a naive count check might miss.
- **Check for linked work items** — a PR must reference at least one Azure Boards work item before it can merge, directly enforcing the "every change traces to a tracked reason" discipline, tightly coupling Repos and Boards the same way Bitbucket's Smart Commits couple Bitbucket and Jira (Part 7), though expressed as a hard merge gate rather than a commit-message convention.
- **Require a successful build** — the same required-status-check concept covered for every prior platform, referencing a specific YAML pipeline by name.
- **Comment resolution** — every PR comment thread must be marked resolved before merge, Azure's version of Bitbucket's "all tasks resolved" merge check (Part 7).

**Azure Repos has no first-party CODEOWNERS-file equivalent either**, sharing the same gap already flagged for Bitbucket in Part 7 — path-scoped mandatory reviewers on Azure Repos require **branch policies scoped per-path via multiple, separately-configured policies** rather than one declarative file checked into the repo, a meaningfully more manual, UI-driven process than GitHub's or GitLab's single-file CODEOWNERS approach.

---

## Agent Pools — Microsoft-Hosted and Self-Hosted

| Pool type | Scope | Rough equivalent |
|---|---|---|
| **Microsoft-hosted** | Fresh VM per job, Microsoft-managed | GitHub-hosted runners |
| **Self-hosted (private pool)** | Registered machines, organization-managed | GitHub/GitLab/Bitbucket self-hosted runners |
| **Azure Virtual Machine Scale Set agents** | Self-hosted, but auto-scaling based on demand, Azure-managed provisioning | Closest to GitHub's/GitLab's Kubernetes-executor-based autoscaling runners |

```yaml
pool:
  name: my-self-hosted-pool
  demands:
    - agent.os -equals Linux
    - customCapability -equals gpu-enabled
```

**Pipeline permissions on an agent pool are a distinctive, worth-noting security control** — a specific YAML pipeline must be explicitly authorized to use a given self-hosted agent pool before it can, independent of ordinary repo/project access. This closes a specific gap the search research for this chapter flagged directly: without this authorization layer, *any* pipeline in the project could target a sensitive self-hosted pool just by naming it in `pool:`, regardless of whether that specific pipeline's author should have access to whatever that pool's agents can reach — a materially more granular control than the tag/label-matching model covered for GitHub, GitLab, and Bitbucket's self-hosted runners, which generally rely on tag-matching alone without a separate per-pipeline authorization step.

---

## A Full Realistic Multi-Stage Pipeline

The same build → test → scan → deploy-staging → approval → deploy-production shape from every prior platform chapter, in Azure's four-layer, template-composed model:

```yaml
trigger:
  branches: { include: [main] }

stages:
  - stage: Build
    jobs:
      - job: BuildAndUnitTest
        pool: { vmImage: ubuntu-latest }
        steps:
          - template: templates/build-steps.yml
          - script: npm test
          - task: PublishPipelineArtifact@1
            inputs: { targetPath: dist, artifact: dist }

  - stage: SecurityScan
    dependsOn: Build
    jobs:
      - job: SAST
        pool: { vmImage: ubuntu-latest }
        steps:
          - script: npm audit --audit-level=high

  - stage: DeployStaging
    dependsOn: [Build, SecurityScan]
    jobs:
      - deployment: DeployStagingJob
        environment: staging
        pool: { vmImage: ubuntu-latest }
        strategy:
          runOnce:
            deploy:
              steps:
                - task: DownloadPipelineArtifact@2
                  inputs: { artifact: dist }
                - script: ./deploy.sh --env staging

  - stage: SmokeTest
    dependsOn: DeployStaging
    jobs:
      - job: Smoke
        pool: { vmImage: ubuntu-latest }
        steps:
          - script: curl -f https://staging.example.com/healthz

  - stage: DeployProduction
    dependsOn: SmokeTest
    condition: succeeded()
    jobs:
      - deployment: DeployProdJob
        environment: production      # Approvals + checks configured here = the manual gate
        pool: { vmImage: ubuntu-latest }
        strategy:
          runOnce:
            deploy:
              steps:
                - task: DownloadPipelineArtifact@2
                  inputs: { artifact: dist }
                - script: ./deploy.sh --env production
```

```mermaid
flowchart LR
    Build["Build"] --> Scan["SecurityScan"]
    Build --> DS["DeployStaging"]
    Scan --> DS
    DS --> Smoke["SmokeTest"]
    Smoke --> Gate{"DeployProduction —<br/>Environment approval<br/>+ checks"}
```

---

## Four-Way Comparison — GitHub, GitLab, Bitbucket, and Azure DevOps

The complete comparison this series has been building toward since Part 4 — every dimension covered across all four platform chapters, side by side:

| | GitHub Actions | GitLab CI/CD | Bitbucket Pipelines | Azure DevOps |
|---|---|---|---|---|
| **Pipeline file(s)** | Many independent workflow files | Typically one `.gitlab-ci.yml` | One `bitbucket-pipelines.yml`, named trigger sections | One `azure-pipelines.yml`, four-layer nesting |
| **Default scheduling** | Parallel unless `needs:` | Sequential-by-stage unless `needs:` | Fully sequential unless `parallel:` | Sequential-by-stage unless `dependsOn` |
| **Native matrix builds** | Yes | Yes | **No** | Yes (named legs, `maxParallel:`) |
| **Reusable steps/jobs** | Composite Actions / Reusable Workflows | `include:` / CI/CD Components | Pipes (narrow) / YAML anchors (single-repo) | `template:` (any layer) / `extends:` (enforcing) |
| **Non-bypassable centralized governance** | Org rulesets (access control only, not injected jobs) | Compliance pipelines (injected, non-removable) | No direct equivalent | `extends:` templates (structurally non-bypassable) |
| **Manual approval gate** | Environment required reviewers | Protected environment + `when: manual` | Deployment + `trigger: manual` | Environment Approvals (+ Business Hours, REST API checks) |
| **OIDC to cloud — setup friction** | Manual trust-policy authoring | Manual trust-policy authoring | Manual trust-policy authoring | Lowest — Service Connection UI absorbs most complexity |
| **Built-in SAST/DAST/dependency scanning** | Opt-in (GHAS + Actions) | Deepest — near one-line `include` | Thinnest — mostly third-party Pipes | Opt-in (Microsoft Defender for DevOps + tasks) |
| **CODEOWNERS-equivalent path review** | Yes | Yes (approval rules) | No | No (multiple scoped branch policies instead) |
| **Cross-git-host CI/CD** | No — tied to GitHub repos | No — tied to GitLab repos | No — tied to Bitbucket repos | **Yes** — can build/deploy from any git host |
| **Distinctive strength** | Enormous third-party ecosystem | Deepest built-in security/compliance | Deepest native Jira integration | Enterprise governance depth, cross-platform CI/CD, native test management |

**The pattern worth taking away from this full comparison, more than any single row:** every platform solves the same underlying problems from Part 1's tool-agnostic model — trigger, sequence, isolate, gate, authenticate, scope. Where they genuinely diverge is *how much structure and enforcement the platform itself is willing to own on an organization's behalf* — from GitHub's ecosystem-driven, opt-in-everything philosophy at one end, to GitLab's and Azure's centrally-enforceable governance mechanisms at the other, with Bitbucket's leaner, integration-focused approach occupying its own distinct niche built around Atlassian-ecosystem value rather than either raw feature breadth or built-in governance depth. No platform is simply "the best" independent of an organization's actual size, compliance posture, and existing tooling investment.

---

## Common Mistakes

| Mistake | Why it's a problem | Fix |
|---|---|---|
| Still building new pipelines on the Classic designer | Config lives outside version control — invisible to code review, the exact problem CI/CD-as-code exists to solve | Use YAML pipelines for anything new; treat existing Classic pipelines as migration debt |
| Assuming `maxParallel:` controls actual concurrency | The real ceiling is the account's purchased parallel job slots, not the matrix definition | Check the actual parallel-job allocation before assuming a large matrix will run as fast as it "should" |
| Storing a Service Connection with a long-lived Service Principal secret when Workload Identity Federation was available | Same indefinite-exposure risk as any static credential | Use Workload Identity Federation (automatic) for any new Service Connection |
| Forgetting `DownloadPipelineArtifact@2` in a later stage | Unlike GitLab/Bitbucket's automatic propagation, Azure requires it explicitly | Always pair `PublishPipelineArtifact@1` with an explicit download step in the consuming stage/job |
| Using `template:` when the goal is actually non-bypassable enforcement | `template:` composition can still be selectively ignored/reordered by the calling file | Use `extends:` when the requirement is genuinely "this cannot be skipped," not just "this is available to reuse" |
| No pipeline-level authorization configured on a sensitive self-hosted agent pool | Any pipeline in the project can target it by name, regardless of who authored that pipeline | Explicitly scope which YAML pipelines may use a sensitive pool via its permissions settings |
| No path-scoped branch policy on infra/pipeline-definition directories | Azure Repos has no CODEOWNERS-equivalent single file — an easy gap to forget without one | Configure a dedicated branch policy scoped to those specific paths, mirroring CODEOWNERS' intent manually |
| Using `runOnce` for a genuinely high-risk production deploy | Deploys everything in one pass, with no built-in wave/health-check/rollback structure | Use `rolling` or `canary` strategy for anything where blast-radius control matters, and use the `on: failure:` hook |
| Assuming a decorator can be scoped to just one project | Decorators are organization-wide by design — that's the entire point of the guarantee they provide | Use `extends:` templates instead when the requirement is project-specific, not truly organization-wide |

---

## Worked Practice Problems

**Problem 1:** A team's matrix build defines 12 legs (`maxParallel: 12`) but the pipeline consistently takes as long as if only 2 ran at a time. What's the most likely explanation, and how would you confirm it?

*Answer:* The account most likely has only 1-2 purchased Microsoft-hosted parallel job slots — `maxParallel:` sets an upper bound on concurrency *within the matrix's own logic*, but the actual number of legs that can run simultaneously is capped by however many parallel job slots the organization has available across its entire Azure DevOps usage, not by anything the pipeline YAML itself declares. Confirm by checking Organization Settings → Parallel jobs; if it shows 1-2 available Microsoft-hosted slots, that's the real bottleneck, and the fix is either purchasing additional parallel job slots or moving some of the matrix legs to a self-hosted pool with its own separate concurrency capacity.

**Problem 2:** A platform team wants to guarantee that a specific security-scan stage runs on every pipeline in every project, with no individual project team able to remove or skip it — comparable to what GitLab's compliance pipelines (Part 6) provide. Which Azure DevOps mechanism achieves this, and specifically why does `template:` alone not suffice?

*Answer:* `extends:`, not plain `template:`. A pipeline using `template:` to pull in a security-scan step still fully controls its own overall stage list — nothing stops that same pipeline from simply not including the template reference, or reordering/conditionally skipping around it, since `template:` is opt-in composition the calling file remains in charge of. `extends:` inverts that control: the *template* defines the pipeline's overall stage structure (including a mandatory security-scan stage the template itself always includes), and the calling file can only fill in the specific parameter slots the template exposes — it structurally cannot add, remove, or reorder the template-defined stages, which is exactly the non-bypassable guarantee the requirement calls for.

**Problem 3:** An organization currently hosts code on GitHub but wants Azure DevOps's richer environment-approval checks (specifically, the "Invoke REST API" gate tied to an internal change-freeze calendar) for production deployments, without migrating source control off GitHub. Is this achievable, and how?

*Answer:* Yes — this is precisely the cross-git-host capability that distinguishes Azure DevOps from the other three platforms in this series. Azure Pipelines can be configured against a GitHub repository as its source (via a GitHub service connection for repo access), triggering Azure Pipelines runs off GitHub push/PR events while keeping GitHub as the actual code host, PR review surface, and everything else. The `azure-pipelines.yml` file itself would live in the GitHub repo; Azure DevOps's Environments, Approvals, and the REST API check specifically would be configured entirely within Azure DevOps, independent of GitHub having any equivalent feature natively. This is a genuinely available, real-world pattern precisely because Azure Pipelines was built to not require Azure Repos specifically — none of GitHub Actions, GitLab CI/CD, or Bitbucket Pipelines offer the reverse (using their CI/CD engine against a foreign platform's repos).

**Problem 4:** A team migrating from a Kubernetes-native rolling deployment (handled entirely by the cluster, per this course's Kubernetes deep-dive) to Azure Pipelines asks whether they should express their canary rollout using Azure's native `canary:` deployment strategy or just let the pipeline trigger a `kubectl apply` and let Kubernetes handle the rollout itself, as before. What's the actual tradeoff?

*Answer:* Both are legitimate, and the choice hinges on who should own the canary logic and health-check decisions. Letting Kubernetes handle it (the pipeline just applies a manifest with an updated image, and the cluster's own rolling-update or a service-mesh-based canary controller manages the wave-by-wave rollout) keeps that logic co-located with the runtime platform already responsible for it, and avoids duplicating rollout logic in two places — generally the better default when the team already has mature Kubernetes-native canary/rollout tooling (e.g. Argo Rollouts, covered as a suggested topic in this course). Using Azure's native `canary:` strategy instead centralizes the wave/health-check/rollback logic directly in the pipeline definition, visible in the same file as everything else — genuinely useful when the deployment target *isn't* Kubernetes at all (a VM fleet, an App Service slot swap) and has no native rollout controller of its own to delegate to. The mistake to avoid is doing both redundantly — layering Azure's canary waves on top of Kubernetes' own rolling update without reconciling which system is actually making the wave-timing and health-check decisions.

---

## Summary and What's Next

Azure DevOps closes out this series' platform tour with a four-layer pipeline model (Stages → Jobs → Steps, with an optional top layer) reflecting its enterprise, multi-team delivery-process roots, and a `template:`/`extends:` reusability mechanism that is genuinely the most flexible of the four platforms — spanning any structural layer, with `extends:` specifically providing the same kind of non-bypassable centralized governance GitLab's compliance pipelines provide, via a different mechanism. Workload Identity Federation delivers the same OIDC-based, credential-free cloud authentication established twice already in this series, with meaningfully lower pipeline-author-facing setup friction than GitHub's or GitLab's more explicit, manually-configured equivalents. Environments with Approvals and Checks provide the richest set of deployment-gate types among the four platforms, including business-hours restrictions and arbitrary REST API-driven gates. Azure DevOps's single most structurally distinctive capability — building and deploying from any git host, not only its own Azure Repos — has no equivalent anywhere else in this series.

This completes the four-platform tour this series set out to cover: GitHub (Parts 4-5), GitLab (Part 6), Bitbucket (Part 7), and Azure DevOps (this chapter) — four different answers to the same underlying CI/CD model established tool-agnostically back in Part 1, each shaped by a different platform philosophy: ecosystem breadth, integrated-platform depth, Atlassian-suite integration, and enterprise governance flexibility, respectively. The right choice for any real team depends on organizational context far more than on any single feature comparison — existing tooling investment, compliance requirements, team size, and which adjacent ecosystem (GitHub's Marketplace, GitLab's built-in scanning, Atlassian's Jira, or Azure's enterprise governance and cross-platform reach) actually maps onto that team's real, current needs.

Whichever platform a given engagement lands on, the transferable skill this four-chapter tour was built to leave behind is the mapping exercise itself: given a fifth platform this series never covers, know which questions to ask — how does it trigger, sequence, and gate; how does it authenticate outward without a stored secret; how, if at all, can a control be made non-bypassable — and go find that platform's own specific answer.
