Azure DevOps

Azure DevOps YAML Template Libraries: Reusable Stage, Job & Step Templates at Scale

You have eleven services, each with its own azure-pipelines.yml, and each file is four hundred lines that are ninety percent identical: the same build-test-scan-publish dance, the same AzureCLI@2 deploy block, the same approval gate. When the security team mandates a new SBOM step, you open eleven pull requests. When someone fat-fingers a pool name in service number seven, it ships to production with the wrong agent and nobody notices for a week. This is pipeline sprawl — the tax every team pays for treating CI/CD as eleven snowflakes instead of one library with eleven callers.

Azure DevOps YAML templates are the fix. A template is a .yml file containing a reusable fragment of a pipeline — a single step, a group of steps, a whole job, or an entire stage — which other pipelines pull in and parameterise. You author the build-test-deploy logic once, in one file (ideally one repository), give it parameters for the bits that vary per caller (project name, environment, agent pool), and every pipeline references it. The new SBOM step becomes one commit. The fat-fingered pool name becomes a typed parameter with a values: allow-list that fails validation before the run starts.

This article is a step-by-step implementation guide. By the end you will have built a real, four-level template library — step → job → stage → an extends governance template — published it in a dedicated repository, pinned consumers to a specific version with a ref, and consumed it from a separate pipeline that does a genuine build-and-deploy. You will do every piece in both the portal and the az CLI (az pipelines / az repos), with a Bicep companion for the deploy target, expected output at each step, validation, and teardown. We treat the four template types as a layered design — each composing the level below — and go deep on the single hardest part in practice: whether a value is resolved at compile time (template expressions, ${{ }}) or at runtime (variables, $( )), because almost every confusing template bug lives in that gap.

What problem this solves

Without templates, a pipeline is copy-paste-inherited. Team A writes a good pipeline; Team B copies it; six months later they have drifted, and the “standard” build exists in fourteen mutually-incompatible dialects. There is no single place to fix a bug, add a control, or upgrade a task version. Worse, there is no enforcement: nothing stops a team deleting the security scan, skipping the approval, or deploying from an unprotected branch — each pipeline is sovereign, editable by whoever owns the repo.

What breaks, concretely: a mandated change (new compliance step, task major-version bump, an end-of-life agent pool) requires touching every pipeline by hand, so it never fully lands and you carry partial coverage for months. A subtle defect — a wrong condition, a missing dependsOn, a deploy that runs on PR builds — is duplicated everywhere and fixed nowhere. Audits become archaeology: “show me that every production deploy goes through a manual approval and a security scan” has no single answer across fourteen pipelines.

Who hits this: any organisation past three or four pipelines. It bites hardest on platform/DevOps teams accountable for CI/CD standards with no mechanism to enforce them, regulated shops that must prove every deploy ran a fixed set of gates, and fast-growing teams where a new microservice should inherit the golden path on day one. The fix inverts ownership: a central template library owns the how, individual pipelines own only the what (their parameters), and an extends template makes the golden path mandatory, not optional.

Here is the whole field in one frame — the four template kinds, what each wraps, and when you reach for it:

Template kind What it wraps Inserted with Typical owner Use it when
Step template One or more steps: - template: under steps: Platform team A reusable action (build, test, scan, publish) used in many jobs
Job template One or more jobs: - template: under jobs: Platform team A reusable unit of work with its own pool/workspace (a whole build job)
Stage template One or more stages: - template: under stages: Platform team A reusable phase (a full “deploy to an environment” stage)
Variable template A variables: block - template: under variables: Platform / app Shared constants and settings injected into many pipelines
Extends template The entire pipeline shape extends: template: at root Security / governance You must enforce structure and gates an app team cannot remove

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should be comfortable with a basic Azure DevOps YAML pipeline: a pipeline file lives in a repo, it has trigger, pool, and steps (or stagesjobssteps), and a run executes on a Microsoft-hosted or self-hosted agent. You should know what a task is (e.g. AzureCLI@2, DotNetCoreCLI@2), what a stage/job/step is, and how a service connection lets a pipeline authenticate to Azure. Basic Git (branches, tags, commits) is assumed, and you will want an Azure DevOps organization and project plus a subscription for the deploy step.

This sits in the DevOps / Pipeline-as-Code track. It is the structural backbone under everything else: once your logic lives in templates, the Azure DevOps Library: Variable Groups, Secure Files & Key Vault-Linked Secrets layer supplies the secret values your templates consume, and Azure Artifacts: Private Feeds, Upstream Sources & Package Versioning Strategy is what a publish step pushes to. When a templated run won’t start, the failure usually lands in Azure DevOps Pipelines Stuck in Queue: No Available Agents & Parallelism Limits. The IaC your deploy stage applies is authored with Bicep Modules, Loops & Conditions: Authoring Reusable, DRY Infrastructure Templates — note the symmetry: Bicep modules are to infrastructure what YAML templates are to pipelines.

A quick map of who owns what once the library exists, so responsibility is unambiguous:

Layer What lives here Who owns it What a consumer can change
Extends template (root) Mandatory shape: gates, approvals, allowed pools Security / platform Only the parameters it exposes
Stage templates “Deploy to env X” phases, approvals Platform team Stage parameters (env, region)
Job templates Build/test units, pool, workspace Platform team Job parameters (project, config)
Step templates Single actions (build, scan, publish) Platform team Step parameters (paths, flags)
Consumer pipeline extends: or - template: + parameter values App team Their values only
Variable groups / Key Vault Secret values the templates read App + platform The secret contents

Core concepts

Five mental models make every later step obvious.

A template is a text include with typed inputs. When Azure DevOps compiles a pipeline, a - template: reference is expanded in place — the referenced file’s content is spliced into the parent with its parameters: substituted. The unit of reuse is set by what kind of node the template contributes: a file whose top-level key is steps: is a step template (insertable only where steps are legal); jobs:job template; stages:stage template; variables:variable template. You cannot insert a job template under steps: — the shape must match the slot.

Parameters are typed, compile-time; variables are strings, runtime. A parameter has a type and optional default, is passed by the caller, and is substituted before the run with template expression syntax ${{ parameters.name }}. A variable is a runtime string, referenced with macro syntax $(name) (or runtime-expression $[ ]), whose value may not exist until a prior step sets it. This single distinction is the root of most template confusion, and the section on it below is the most important in the article.

Templates compose into layers. A step template is the atom. A job template consumes step templates; a stage template consumes job templates; an extends template consumes stage templates and wraps the whole pipeline. You build bottom-up — atoms first — and each layer provides structure and defaults while delegating the level below. This is exactly how Bicep modules nest: small, single-purpose, parameterised units.

Cross-repo reuse needs a declared resources and a ref. Templates in another repository aren’t visible until you declare that repo under resources: repositories: (alias, name, ref). The ref pins which version you consume: a branch (refs/heads/main — moves with the branch), a tag (refs/tags/v1.4.0 — immutable if you don’t move it), or an exact commit SHA. Pinning is the difference between “my pipeline silently changed because the platform team merged to main” and “my pipeline upgrades when I bump the tag, deliberately.”

An extends template enforces; a regular template merely offers. A normal - template: insertion is opt-in — a consumer can choose not to call it. An extends: template is different: the consumer’s entire pipeline is the extends template, and it can only fill the parameters the template exposes. Pass a stepList parameter, wrap it with mandatory steps the template controls, add branch policies and environment checks, and a platform team makes a security scan or a manual approval non-removable. Offering is a convention; extending is a control.

The vocabulary in one table

Pin down every moving part before the deep sections. The glossary repeats these for lookup; this is the mental model side by side:

Concept One-line definition Syntax / where Why it matters
Step template Reusable steps: fragment - template: x.yml under steps: The atom of reuse
Job template Reusable jobs: fragment - template: x.yml under jobs: A whole build unit with a pool
Stage template Reusable stages: fragment - template: x.yml under stages: A full deploy phase
Variable template Reusable variables: block - template: x.yml under variables: Shared constants
Extends template The whole pipeline shape extends: { template: x.yml } Enforces structure/gates
Parameter Typed, compile-time input parameters: [{name,type,default}] The knob a caller turns
Template expression Compile-time substitution ${{ ... }} Evaluated before the run
Macro variable Runtime string substitution $( name ) Value known at run time
Runtime expression Runtime, lazily evaluated $[ ... ] Cross-stage/job values
resources: repositories Declared external repo top-level resources: Makes cross-repo templates visible
ref The version of a repo to use ref: refs/tags/v1.0.0 Pins the template version
extends + stepList Inject caller steps into a fixed shell type: stepList param Enforce gates around caller steps

Template types, layer by layer

This is the bulk. We build the library bottom-up: the step template (the atom), then the job template that consumes it, then the stage template that consumes the job, then the variable template, then the extends template that wraps everything. For each: what it is, how you declare its parameters, the default-and-allow-list discipline, and the gotcha.

Step templates — the atom

A step template is a .yml file whose top-level key is steps:. It contributes one or more steps wherever steps are legal — inside a job, or the implicit single job of a simple pipeline. This is where the actual work lives: a build, a test run, a scan, a publish.

# templates/steps/build-dotnet.yml  (a STEP template)
parameters:
  - name: project          # which .csproj/.sln to build
    type: string
  - name: configuration
    type: string
    default: 'Release'
    values: [ 'Debug', 'Release' ]   # allow-list: anything else fails validation
  - name: dotnetVersion
    type: string
    default: '8.0.x'

steps:
  - task: UseDotNet@2
    inputs:
      packageType: 'sdk'
      version: ${{ parameters.dotnetVersion }}
  - task: DotNetCoreCLI@2
    displayName: 'Build ${{ parameters.project }} (${{ parameters.configuration }})'
    inputs:
      command: 'build'
      projects: ${{ parameters.project }}
      arguments: '--configuration ${{ parameters.configuration }}'

A consumer inserts it under steps: and passes the parameters:

steps:
  - template: templates/steps/build-dotnet.yml
    parameters:
      project: 'src/Api/Api.csproj'
      configuration: 'Release'

The values: list is your cheapest governance: configuration: 'Staging' fails at compile time, never reaching an agent. Every parameter substitution uses the compile-time ${{ }} form, because the value is known before the run.

The parameter types you can declare:

Parameter type Accepts Example default Notes / gotcha
string Any text 'Release' The default if type: is omitted
number Numeric (still YAML-quoted often) 1 Compared numerically in ${{ }}
boolean true / false false Use in ${{ if }} conditions
object A YAML map or list {} / [] For structured config; iterate with each
step A single step Inject one caller-supplied step
stepList A list of steps [] Inject many caller steps (extends pattern)
job / jobList One/many jobs [] Inject jobs into a stage template
deployment / deploymentList One/many deployment jobs [] For environment-targeted jobs
stage / stageList One/many stages [] Inject stages (root extends pattern)

Job templates — a unit of work with a pool

A job template wraps one or more jobs:. A job owns its pool, workspace, timeout, and a set of steps — which are very often themselves step-template references. This is where “a build” becomes a self-contained, reusable unit.

# templates/jobs/build-job.yml  (a JOB template that consumes the step template)
parameters:
  - name: project
    type: string
  - name: configuration
    type: string
    default: 'Release'
  - name: pool
    type: string
    default: 'ubuntu-latest'
    values: [ 'ubuntu-latest', 'windows-latest' ]   # only sanctioned pools
  - name: runTests
    type: boolean
    default: true

jobs:
  - job: build
    displayName: 'Build & test'
    pool:
      vmImage: ${{ parameters.pool }}
    steps:
      - template: ../steps/build-dotnet.yml      # compose the atom
        parameters:
          project: ${{ parameters.project }}
          configuration: ${{ parameters.configuration }}
      - ${{ if eq(parameters.runTests, true) }}:  # COMPILE-TIME conditional insertion
        - task: DotNetCoreCLI@2
          displayName: 'Test'
          inputs:
            command: 'test'
            projects: ${{ parameters.project }}

The ${{ if eq(parameters.runTests, true) }}: block is conditional insertion: when runTests is false, the test step is not even present in the compiled pipeline — removed at compile time, not skipped at runtime. That differs fundamentally from a runtime condition: (which keeps the step but marks it skipped). The next deep section is dedicated to this crux.

Reach for a job template when the reusable unit needs its own pool/agent, its own workspace, must run in parallel with other units, or targets an environment (use a deployment job for that). Reach for a step template when it’s a single action (build, scan, publish) reused across many different jobs — steps are sequential and pool-less, jobs parallelise and carry the pool.

Stage templates — a full phase

A stage template wraps one or more stages:. A stage is a major phase (Build, Deploy-Dev, Deploy-Prod) with its own dependsOn, condition, and jobs — frequently job-template references. The classic stage template is “deploy to one environment,” parameterised by environment name, so one definition produces Dev, Test, and Prod stages.

# templates/stages/deploy-stage.yml  (a STAGE template)
parameters:
  - name: environment
    type: string                         # 'dev' | 'test' | 'prod'
  - name: serviceConnection
    type: string
  - name: resourceGroup
    type: string
  - name: dependsOn
    type: object
    default: []                          # let the caller wire ordering

stages:
  - stage: deploy_${{ parameters.environment }}
    displayName: 'Deploy to ${{ parameters.environment }}'
    dependsOn: ${{ parameters.dependsOn }}
    jobs:
      - deployment: deploy
        environment: ${{ parameters.environment }}   # binds approvals/checks
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureCLI@2
                  displayName: 'az deployment group create'
                  inputs:
                    azureSubscription: ${{ parameters.serviceConnection }}
                    scriptType: 'bash'
                    scriptLocation: 'inlineScript'
                    inlineScript: |
                      az deployment group create \
                        --resource-group ${{ parameters.resourceGroup }} \
                        --template-file $(Pipeline.Workspace)/drop/main.bicep

Binding the deployment job to an environment: attaches approvals and checks — configure a manual-approval check on the prod environment once, and every stage targeting it inherits the gate. dependsOn is an object parameter so the caller controls ordering (Deploy-Prod dependsOn Deploy-Test, etc.).

The constructs that legally live at each template level — get this wrong and you hit “the template references an unexpected value”:

You can put… In a step template In a job template In a stage template
steps: (tasks/scripts) Yes (top level) Inside a job Inside a job in a stage
pool: No Yes Per job
job: / deployment: No Yes (top level) Inside stages[].jobs
dependsOn, condition (stage) No Job-level only Yes (top level)
environment: (approvals) No On a deployment job On a deployment job
- template: (step) Yes Yes (under steps) Yes (deep under steps)

Variable templates — shared constants

A variable template contributes a variables: block. Use it for non-secret values shared across pipelines (region, image-tag prefix, common flags). Secrets belong in a variable group linked to Key Vault, not a checked-in template.

# templates/vars/common.yml  (a VARIABLE template)
parameters:
  - name: environment
    type: string
    default: 'dev'

variables:
  - name: azureRegion
    value: 'centralindia'
  - name: imageTagPrefix
    value: 'svc'
  - ${{ if eq(parameters.environment, 'prod') }}:
    - name: minReplicas
      value: '3'          # prod-only constant, inserted at compile time

Consumed under variables::

variables:
  - template: templates/vars/common.yml
    parameters:
      environment: 'prod'

Note the variables: list form (- name:/value:) — required when you mix templates and conditional insertion. The map form (key: value) does not allow - template: entries.

Extends templates — enforcing the shape

An extends template is the governance capstone. Instead of choosing to call your templates, the consumer’s whole pipeline declares extends: your template and supplies only parameters. Because you own the outer shape, you wrap the caller’s steps with mandatory ones they cannot remove.

# templates/governed-pipeline.yml  (an EXTENDS template)
parameters:
  - name: buildSteps        # the caller's steps go HERE, sandwiched by ours
    type: stepList
    default: []

stages:
  - stage: build
    jobs:
      - job: build
        pool: { vmImage: 'ubuntu-latest' }
        steps:
          - task: CredScan@3                 # MANDATORY pre-step (the caller can't drop it)
            displayName: 'Mandatory secret scan'
          - ${{ each step in parameters.buildSteps }}:  # splice the caller's steps
            - ${{ step }}
          - task: PublishBuildArtifacts@1     # MANDATORY post-step
            displayName: 'Mandatory artifact publish'

The consumer:

# azure-pipelines.yml in the APP repo — it can ONLY fill buildSteps
extends:
  template: templates/governed-pipeline.yml@templatesRepo
  parameters:
    buildSteps:
      - script: dotnet build
      - script: dotnet test

The app team supplies what to build; the template guarantees the scan runs before and the publish after, with no syntax for the consumer to remove them. Pair it with a branch policy and a repo/environment check that a run must extend a named template for hard enforcement.

When to use extends versus plain - template: insertion:

Situation Plain - template: extends:
Reuse logic, trust the team Yes Overkill
Enforce mandatory gates (scan, approval) Can be skipped Yes
Caller needs full control of structure Yes No (you own the shape)
Regulated / audited golden path Insufficient Yes
Inject caller steps into a fixed shell Awkward Yes (stepList)
One reusable action among many Yes No

Compile-time expressions vs runtime variables

This is the section that prevents most template bugs. Azure DevOps evaluates a pipeline in two phases, and a value belongs to exactly one. Get the phase wrong and your conditional inserts the wrong thing, your variable is empty, or your if never fires.

Phase 1 — compile time (template expansion). Before any agent runs, the service expands templates, substitutes parameters, evaluates ${{ }} template expressions, and resolves ${{ if }} / ${{ each }} insertions. Everything in ${{ }} is known now: parameter values and a subset of compile-time predefined variables. The output is one fully-expanded YAML document.

Phase 2 — runtime. The agent runs that document. Macro variables $( ) are substituted as each step runs; runtime expressions $[ ] are evaluated for things known only during the run (a previous job’s output, a variable a script set). A condition: is a runtime decision — the step exists in the document but may be skipped.

The practical rule: ${{ parameters.x }} decides what the pipeline is; $(x) uses a value while it runs. Conditional insertion (${{ if }}) changes the document; conditional execution (condition:) changes whether an existing step runs.

Aspect ${{ }} template expression $( ) macro variable $[ ] runtime expression
Evaluated Compile time (expansion) Runtime (step start) Runtime (job/stage start)
Operates on Parameters, compile-time vars Variables (strings) Variables, dependencies
Can change pipeline structure Yes (if/each insertion) No No
Sees a prior step’s output var No (doesn’t exist yet) Yes Yes
Typical use Parameters, conditional insertion Use a value in a script/input dependsOn outputs, cross-stage
If you misuse it Empty/literal text or no insertion $(x) printed literally Unresolved at compile, errors

A worked contrast that bites everyone:

parameters:
  - name: deployProd
    type: boolean
    default: false

steps:
  # COMPILE-TIME insertion: if false, this step is absent from the run entirely
  - ${{ if eq(parameters.deployProd, true) }}:
    - script: echo "Prod deploy step compiled in"

  # RUNTIME condition: the step is always compiled in, but may be skipped
  - script: echo "Always present, runs only on main"
    condition: eq(variables['Build.SourceBranch'], 'refs/heads/main')

  # Using a runtime variable inside a script (correct macro syntax)
  - script: echo "Building branch $(Build.SourceBranchName)"

Two failure modes to recognise on sight:

What you wrote What you expected What actually happens The fix
${{ if eq(variables.myOutput, 'x') }} where myOutput is set by a prior step Insert when the prior step set it Never inserts — output doesn’t exist at compile time Use a runtime condition: with $[ dependencies… ]
condition: eq('${{ parameters.env }}', 'prod') Runtime check of the param Works, but the param was already known — you could have used ${{ if }} to not emit the step at all Prefer ${{ if }} for parameter-driven structure
pool: $(poolName) Pick the pool from a variable pool is read at compile time; macro may not resolve Make poolName a parameter and use ${{ }}
displayName: $(buildId) in a template Show the build id Often fine, but template-time fields prefer ${{ }} Use predefined ${{ variables… }} where compile-time

Template expression functions you will actually use inside ${{ }}:

Function Does Example
eq(a,b) / ne(a,b) Equality / inequality ${{ if eq(parameters.env,'prod') }}
and(...) / or(...) / not(x) Boolean logic ${{ if and(eq(...),ne(...)) }}
coalesce(a,b) First non-empty ${{ coalesce(parameters.tag, 'latest') }}
format('{0}-{1}',a,b) String build displayName: ${{ format('Deploy {0}', parameters.env) }}
contains(s,sub) Substring test ${{ if contains(parameters.env,'prod') }}
each x in list Iterate (insertion) ${{ each s in parameters.steps }}
convertToJson(o) Object → JSON string passing structured config

Cross-repo templates and version pinning

A real library lives in its own repository for independent versioning, ownership, and branch policies. Consumers in other repos declare that repository as a resource and pin a ref:

# Consumer pipeline in the APP repo
resources:
  repositories:
    - repository: templatesRepo        # the alias you'll use as @templatesRepo
      type: git                        # 'git' = same Azure DevOps org
      name: Platform/pipeline-templates # Project/Repo
      ref: refs/tags/v1.2.0            # PIN to an immutable tag

stages:
  - template: templates/stages/deploy-stage.yml@templatesRepo   # note @alias
    parameters:
      environment: 'prod'
      serviceConnection: 'sc-prod'
      resourceGroup: 'rg-shop-prod'

The @templatesRepo suffix tells the compiler “this template lives in that declared repo, not this one.” Omit it and the compiler looks in the local repo and fails to find the file.

The ref choices — the single most consequential decision in a shared library:

ref value Resolves to Stability When to use Risk
refs/heads/main Latest commit on a branch Moves every merge Internal teams wanting auto-updates A platform merge silently changes your run
refs/tags/v1.2.0 The tagged commit Immutable if you never move the tag Production pinning (recommended) A re-tagged version changes silently
Exact commit SHA One commit Fully immutable Reproducible/audited builds Manual bumps; no auto-fixes
refs/heads/release/* A release branch tip Moves within a release line Patch-level auto-updates Still moves; less than main

Versioning the library matters as much as pinning the consumer. Treat it like a package:

Library practice Why How
Semantic version tags (v1, v1.2, v1.2.0) Consumers pin a precision they trust git tag v1.2.0 on the templates repo
A moving major tag (v1) Auto-patch within a major Re-point v1 → newest v1.x on release
Branch policy on main No unreviewed change to the golden path Require PR + reviewers on the templates repo
Changelog per release Consumers know what a bump does CHANGELOG.md in the templates repo
Deprecate, don’t break Don’t silently change parameter meaning Add new params with defaults; warn, then remove

Cross-repo also affects checkout: by default only the consumer’s own repo (self) is checked out. If a job needs the template repo’s files (not just its templates), add - checkout: templatesRepo. Templates are fetched by the service regardless — checkout is only for needing their files as content on the agent.

Architecture at a glance

The library is a left-to-right composition. On the far left is the app team’s consumer pipeline — a thin file that contains almost no logic, just an extends: (or a set of - template: references) plus the parameter values that make it their service. That file declares the central pipeline-templates repository as a resources: repositories: entry, pinned to a tagged ref, so it reads from an immutable, separately-governed source rather than copy-pasted YAML. When a run is queued, Azure DevOps fetches the pinned templates, expands them in place, substitutes the typed parameters at compile time, and produces one fully-resolved pipeline document.

That document is the composed library: the extends template wraps the whole shape, embedding mandatory gates (a secret scan before, an artifact publish after) around the caller’s stepList; inside it, stage templates form each phase (Build, Deploy-Dev, Deploy-Prod); each stage’s jobs are job templates carrying the pool and workspace; and each job’s steps are step templates — the atoms doing the build, test, scan, and the AzureCLI@2/Bicep deploy. The final stage binds to a protected environment, which is where the manual-approval check fires before anything reaches production. Read the diagram left to right: consumer → pinned templates repo → compile-time expansion → the four nested template layers → the gated deploy into Azure.

Left-to-right Azure DevOps template library architecture: an app team consumer pipeline references a version-pinned central pipeline-templates repository, which at compile time expands into nested extends, stage, job and step templates, wrapping mandatory scan and publish gates around the caller's steps, with the final deploy stage passing through a protected environment approval into an Azure resource group; numbered badges mark the ref-pinning, parameter-validation, compile-time-expansion, mandatory-gate and approval-gate control points

Real-world scenario

Northwind Retail runs an e-commerce platform on Azure: a storefront API, catalog, payments, an inventory worker, and seven smaller microservices — eleven pipelines, each grown by copy-paste from the storefront’s original. By 2026 they had drifted badly: catalog still used DotNetCoreCLI@2 with a deprecated argument; payments deployed straight from feature branches because someone had deleted the condition; three services had no security scan at all. When the platform team was told to add a mandatory SBOM generation step for a compliance audit, they estimated three weeks of eleven coordinated pull requests — with a high chance some would be missed.

Instead they spent a week building a library in a new pipeline-templates repo: a build-dotnet.yml step template (with a configuration allow-list so nobody could ship a Debug build), a build-job.yml job template pinning the pool to ubuntu-latest, a deploy-stage.yml stage template parameterised by environment and bound to protected dev/test/prod environments, and the keystone — a governed-pipeline.yml extends template wrapping every caller’s build steps with a mandatory CredScan before and SBOM + artifact publish after. They tagged it v1.0.0.

The migration converted the eleven pipelines one at a time to extends: governed-pipeline.yml@templatesRepo, each now a fifteen-line file: a resources block pinned to refs/tags/v1.0.0, an extends, and a buildSteps list. The payments-from-feature-branch bug vanished because the deploy stage’s condition now lived in the template, not the deletable consumer. The deprecated argument vanished because the build logic was central. When the SBOM mandate landed again two months later for a second framework, it was one commit and a tag bump to v1.1.0; teams adopted it by changing one line — ref: refs/tags/v1.1.0 — at their own pace, with a changelog telling them exactly what changed.

The lesson: their first cut pinned every consumer to refs/heads/main. Two days in, an engineer merged a half-finished parameter rename to main, and all eleven pipelines failed validation at once — a self-inflicted org-wide CI/CD outage. They reverted, moved every consumer to tag pinning, and adopted a rule: main is for development, consumers reference only tags, and a tag is cut only after the library’s own validation pipeline (a matrix of sample consumers compiled via the Preview API) goes green. The second SBOM mandate then took an afternoon instead of three weeks, and the audit passed because every pipeline provably extended one governed template.

Advantages and disadvantages

Advantages Disadvantages
One place to fix a bug or add a control (DRY) A bad change to a shared template breaks many pipelines at once
Typed parameters + values: catch errors at compile time Compile-time vs runtime distinction is a real learning curve
extends makes gates non-removable (real governance) extends reduces consumer flexibility (sometimes too much)
Version pinning gives controlled, opt-in upgrades Pinning to main causes silent breakage; tags require discipline
New services inherit the golden path on day one An over-abstracted library can be harder to read than inline YAML
Cross-repo separation enables independent ownership Cross-repo adds resources/ref/checkout complexity
Conditional insertion keeps runs lean (no skipped clutter) Deeply nested templates make a run’s source-of-truth harder to trace

When each side matters: the advantages dominate the moment you pass ~3–4 pipelines or have any compliance requirement — consolidation and enforcement pay for themselves, and typed parameters eliminate a whole class of “wrong pool / wrong config” incidents. The disadvantages matter most for very small teams (one or two pipelines, where a library is premature abstraction) and genuinely bespoke pipelines that share little. The compile-time/runtime learning curve is real but one-time — budget an afternoon for ${{ }} vs $( ) and it stops being a source of bugs. The cardinal rule: pin to tags, never main, and you avoid the one failure mode (shared-template breakage) that turns the biggest advantage into the biggest risk.

Hands-on lab

This is the centerpiece. You will build the full library — step → job → stage → extends — publish it in a templates repo, consume it from a separate pipeline pinned to a tag, run a real build-and-deploy, validate without running via the Preview API, and tear it down. Every step is done in both the portal and az CLI, with a Bicep companion for the deploy target.

Prerequisites. An Azure DevOps organization and project (free tier is fine); the Azure CLI with the azure-devops extension; an Azure subscription; and a service connection (you’ll create one). Set defaults so you don’t repeat --org/--project:

# One-time: install the extension and sign in
az extension add --name azure-devops
az login
az devops login    # paste a PAT with Build (read/write) + Code (read/write) scopes

# Set org + project defaults for every later command
ORG=https://dev.azure.com/your-org
PROJECT=Shop
az devops configure --defaults organization=$ORG project=$PROJECT

Expected output: az extension add is silent on success; az devops configure prints nothing — verify with az devops configure --list showing your organization and project.

Step 1 — Create the two repositories

You need a templates repo (the library) and an app repo (the consumer); keeping them separate is the whole point.

Portal: Repos → Files → repo dropdown → New repository, create pipeline-templates, then shop-api.

CLI:

az repos create --name pipeline-templates --query "{name:name, id:id}" -o table
az repos create --name shop-api          --query "{name:name, id:id}" -o table

Expected output: a table with each repo’s name and a GUID id. Clone both locally (or use the portal’s web editor for the next steps):

git clone $ORG/$PROJECT/_git/pipeline-templates
git clone $ORG/$PROJECT/_git/shop-api

Step 2 — Author the step template (the atom)

In the pipeline-templates repo, create templates/steps/build-dotnet.yml:

# pipeline-templates : templates/steps/build-dotnet.yml
parameters:
  - name: project
    type: string
  - name: configuration
    type: string
    default: 'Release'
    values: [ 'Debug', 'Release' ]

steps:
  - task: UseDotNet@2
    inputs:
      packageType: 'sdk'
      version: '8.0.x'
  - task: DotNetCoreCLI@2
    displayName: 'Build ${{ parameters.project }}'
    inputs:
      command: 'build'
      projects: ${{ parameters.project }}
      arguments: '--configuration ${{ parameters.configuration }}'

Validation: every reference is a ${{ parameters.* }} (compile-time) substitution — no runtime $( ) here, because all inputs are known before the run.

Step 3 — Author the job template that composes it

Create templates/jobs/build-job.yml:

# pipeline-templates : templates/jobs/build-job.yml
parameters:
  - name: project
    type: string
  - name: pool
    type: string
    default: 'ubuntu-latest'
    values: [ 'ubuntu-latest', 'windows-latest' ]

jobs:
  - job: build
    displayName: 'Build & publish artifact'
    pool:
      vmImage: ${{ parameters.pool }}
    steps:
      - template: ../steps/build-dotnet.yml      # relative path to the atom
        parameters:
          project: ${{ parameters.project }}
      - task: PublishBuildArtifacts@1
        displayName: 'Publish drop'
        inputs:
          PathtoPublish: '$(Build.ArtifactStagingDirectory)'
          ArtifactName: 'drop'

Validation: the inner - template: ../steps/build-dotnet.yml is a relative path in the same repo (no @alias needed); pool is a parameter with a values: allow-list, so an unsanctioned pool fails at compile time.

Step 4 — Author the stage template

Create templates/stages/deploy-stage.yml — a parameterised “deploy to one environment” phase:

# pipeline-templates : templates/stages/deploy-stage.yml
parameters:
  - name: environment
    type: string
  - name: serviceConnection
    type: string
  - name: resourceGroup
    type: string

stages:
  - stage: deploy_${{ parameters.environment }}
    displayName: 'Deploy to ${{ parameters.environment }}'
    jobs:
      - deployment: deploy
        environment: ${{ parameters.environment }}
        pool: { vmImage: 'ubuntu-latest' }
        strategy:
          runOnce:
            deploy:
              steps:
                - download: current
                  artifact: drop
                - task: AzureCLI@2
                  displayName: 'Deploy Bicep'
                  inputs:
                    azureSubscription: ${{ parameters.serviceConnection }}
                    scriptType: 'bash'
                    scriptLocation: 'inlineScript'
                    inlineScript: |
                      az group create -n ${{ parameters.resourceGroup }} -l centralindia
                      az deployment group create \
                        --resource-group ${{ parameters.resourceGroup }} \
                        --template-file $(Pipeline.Workspace)/drop/main.bicep

Validation: the deployment job binds to environment: ${{ parameters.environment }} (this attaches the approvals from Step 8), and the deploy uses the caller-supplied serviceConnection — the template never hard-codes credentials.

Step 5 — Author the extends (governance) template

Create templates/governed-pipeline.yml — the keystone composing the layers below:

# pipeline-templates : templates/governed-pipeline.yml
parameters:
  - name: project
    type: string
  - name: serviceConnection
    type: string
  - name: resourceGroup
    type: string

stages:
  - stage: build
    jobs:
      - template: jobs/build-job.yml          # compose the job template
        parameters:
          project: ${{ parameters.project }}
  - template: stages/deploy-stage.yml         # compose the stage template
    parameters:
      environment: 'prod'
      serviceConnection: ${{ parameters.serviceConnection }}
      resourceGroup: ${{ parameters.resourceGroup }}

Commit and push the templates repo, then tag it so consumers can pin an immutable version:

git add . && git commit -m "feat: template library v1.0.0"
git push
git tag v1.0.0 && git push origin v1.0.0

Expected output: git push origin v1.0.0 reports * [new tag] v1.0.0 -> v1.0.0. Confirm via CLI:

az repos ref list --repository pipeline-templates --filter tags \
  --query "[].name" -o tsv
# Expect: refs/tags/v1.0.0

Step 6 — Add the deploy target (Bicep) and the consumer pipeline

In the shop-api repo, add a trivial Bicep target main.bicep (a storage account, just to deploy something):

// shop-api : main.bicep — the thing the deploy stage applies
param location string = resourceGroup().location
param namePrefix string = 'shop'

resource sa 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: '${namePrefix}${uniqueString(resourceGroup().id)}'
  location: location
  sku: { name: 'Standard_LRS' }
  kind: 'StorageV2'
  properties: { minimumTlsVersion: 'TLS1_2', allowBlobPublicAccess: false }
}

output storageName string = sa.name

Now the consumer pipeline azure-pipelines.yml — a thin file that pins the templates repo and extends the governed template:

# shop-api : azure-pipelines.yml — the CONSUMER (almost no logic of its own)
trigger:
  - main

resources:
  repositories:
    - repository: templatesRepo
      type: git
      name: Shop/pipeline-templates      # Project/Repo (same org)
      ref: refs/tags/v1.0.0              # PIN to the immutable tag

extends:
  template: templates/governed-pipeline.yml@templatesRepo
  parameters:
    project: '**/*.csproj'
    serviceConnection: 'sc-shop'
    resourceGroup: 'rg-shop-lab'

Validation: the @templatesRepo suffix points the compiler at the declared repo and ref pins v1.0.0. Commit and push (don’t run yet — Step 7 creates the service connection).

Step 7 — Create the service connection

The deploy needs a service connection named sc-shop to your subscription.

Portal: Project settings → Service connections → New service connection → Azure Resource Manager → Workload identity federation (recommended) (or Service principal), pick the subscription, name it sc-shop, and grant access to all pipelines (or this pipeline).

CLI (service-principal style; substitute your IDs):

SUB_ID=$(az account show --query id -o tsv)
TENANT=$(az account show --query tenantId -o tsv)
# Create an SP scoped to the lab RG path (least privilege for the lab)
az ad sp create-for-rbac --name sp-shop-lab \
  --role Contributor --scopes /subscriptions/$SUB_ID > sp.json
# Wire it as an Azure RM service connection
export AZURE_DEVOPS_EXT_AZURE_RM_SERVICE_PRINCIPAL_KEY=$(jq -r .password sp.json)
az devops service-endpoint azurerm create \
  --name sc-shop \
  --azure-rm-service-principal-id   $(jq -r .appId sp.json) \
  --azure-rm-subscription-id        $SUB_ID \
  --azure-rm-subscription-name      "$(az account show --query name -o tsv)" \
  --azure-rm-tenant-id              $TENANT

Expected output: a JSON object with the endpoint id and name: sc-shop. Authorize it for all pipelines if prompted.

Step 8 — Create the pipeline and the protected environment

Register the shop-api pipeline (pointing at its azure-pipelines.yml) and create the prod environment the deploy stage binds to.

Portal: Pipelines → New pipeline → Azure Repos Git → shop-api → Existing YAML file → /azure-pipelines.yml → Save. Then Pipelines → Environments → New environment → prod, and add an Approvals and checks → Approvals with yourself as approver.

CLI:

# Create the pipeline from the YAML in the repo
az pipelines create --name shop-api \
  --repository shop-api --repository-type tfsgit \
  --branch main --yml-path azure-pipelines.yml --skip-first-run true \
  --query "{id:id, name:name}" -o table

Expected output: a table with the pipeline id and name: shop-api. (Environment creation and the approval check are done in the portal — the approval gate is what makes the prod deploy pause for sign-off.)

Step 9 — Validate WITHOUT running (the Preview API)

Before consuming agent minutes, compile-check the pipeline. The Preview run mode expands all templates and returns the final YAML (or the validation error) without queuing a run:

PIPELINE_ID=$(az pipelines show --name shop-api --query id -o tsv)
# Call the Preview API: previewRun=true returns the expanded YAML, runs nothing
az devops invoke \
  --area pipelines --resource preview \
  --route-parameters project=$PROJECT pipelineId=$PIPELINE_ID \
  --http-method POST --api-version 7.1-preview.1 \
  --in-file <(echo '{"previewRun": true}') \
  --query finalYaml -o tsv

Expected output: the fully-expanded pipeline YAML — you’ll see the mandatory build job, the composed steps from build-dotnet.yml, and the deploy_prod stage, all spliced together. If a parameter is missing or a template path is wrong, you get the exact validation error here, for free, instead of after queuing.

Step 10 — Run it and watch the gates fire

Queue a real run:

az pipelines run --name shop-api \
  --query "{id:id, status:status, url:'_build/results?buildId='}" -o table

Expected output: a run id and status: notStarted/inProgress. Watch it:

RUN_ID=$(az pipelines runs list --pipeline-ids $PIPELINE_ID \
  --top 1 --query "[0].id" -o tsv)
az pipelines runs show --id $RUN_ID --query "{status:status, result:result}" -o table

The run executes the build stage (build → publish artifact), then the deploy_prod stage pauses on the manual approval you configured. Approve it in the portal (the run → Review → Approve). The AzureCLI@2 step then runs az deployment group create, creating the storage account in rg-shop-lab. Confirm the deploy:

az storage account list -g rg-shop-lab --query "[].name" -o tsv
# Expect: a 'shop<hash>' storage account

Step 11 — Prove governance: try to skip the gate

This proves enforcement. The consumer’s azure-pipelines.yml has no place to remove the build job or the deploy stage — they live in the extends template. Try to add an arbitrary stage at the root: you can’t, because extends: means the consumer only supplies parameters. The only way to change the build-then-deploy shape is to edit pipeline-templates (a different repo, behind its own branch policy) — exactly the control you wanted.

Step 12 — Upgrade by bumping the tag

Change the library (add a test step to build-dotnet.yml), commit, and tag v1.1.0. The consumer is unaffected until it opts in — proof of pinning:

# in pipeline-templates after the change
git commit -am "feat: add test step" && git push
git tag v1.1.0 && git push origin v1.1.0

Then upgrade the consumer by changing one line in shop-api/azure-pipelines.yml: ref: refs/tags/v1.1.0. Re-run; the new step appears. This is the whole value proposition — controlled, opt-in, one-line upgrades across every consumer.

Validation checklist

What each step proved:

Step What you did What it proves Real-world analogue
2–4 Authored step → job → stage templates Layered composition works bottom-up The reusable library itself
5 Authored the extends template Mandatory gates wrap caller steps The non-removable golden path
5 Tagged v1.0.0 The library is versioned like a package Release management
6 Thin consumer with a pinned ref Consumers carry no logic, only params A new service onboarding in 15 lines
9 Preview API validation You catch errors with zero agent minutes CI gate on the templates repo
10 Real run through the approval The environment gate fires before prod Audited production deploys
11 Couldn’t skip the gate extends enforces, doesn’t merely offer The compliance guarantee
12 Bumped to v1.1.0 Upgrades are one-line and opt-in Org-wide change in one commit

Teardown

# Delete the Azure resources the deploy created
az group delete -n rg-shop-lab --yes --no-wait

# Delete the pipeline, the service connection's SP, and (optionally) the repos
az pipelines delete --id $PIPELINE_ID --yes
az ad sp delete --id $(jq -r .appId sp.json)
rm -f sp.json
# Repos can be deleted in Project settings → Repositories, or:
az repos delete --id $(az repos show --repository shop-api --query id -o tsv) --yes
az repos delete --id $(az repos show --repository pipeline-templates --query id -o tsv) --yes

Cost note. The entire lab runs on Microsoft-hosted agents (the free tier grants a limited number of parallel-job minutes per month) and a single Standard_LRS storage account that exists for minutes — total cost is effectively zero, well under ₹10, and the teardown removes everything. The templates repo itself consumes no agent minutes; only runs do.

Common mistakes & troubleshooting

The failure modes that actually cost you time, as a scannable table first, then the worst offenders expanded with the exact confirm path.

# Symptom Root cause Confirm (exact path / command) Fix
1 “A template expression is not allowed in this context” Used ${{ }} where only runtime is valid (or vice-versa) Read the line; check if the value is compile- or run-time Move parameter-driven logic to ${{ }}; runtime values to $( )/$[ ]
2 A ${{ if }} block never inserts Condition reads a variable (runtime), not a parameter The variable is set by a step/variables: not parameters: Make it a parameter, or switch to a runtime condition:
3 “Template file … not found” on a cross-repo ref Missing @alias, or repo not declared in resources Check resources: repositories: and the @alias suffix Declare the repo; append @templatesRepo to the path
4 “Unexpected value ‘jobs’” (or ‘stages’) Inserted a job/stage template under the wrong key The - template: is under steps: but the file has jobs: Match template kind to slot (step↔steps, job↔jobs)
5 Consumer broke after a platform merge Pinned to refs/heads/main The ref: is a branch, not a tag Pin to refs/tags/vX.Y.Z
6 “Required parameter X not provided” Caller omitted a param with no default The template’s parameters: lacks a default for X Pass X, or give it a sensible default:
7 $(myVar) prints literally as text Variable not defined at runtime, or a typo Run log shows the literal $(myVar) Define the variable; check scope; use $[ ] for outputs
8 Allow-list rejection at queue time Param value not in values: Error names the value and the allowed set Use an allowed value, or extend values: deliberately
9 each loop emits nothing Iterated an empty/object param with wrong type type: is string not object/stepList Declare the right collection type
10 Deploy stage runs on PR builds No condition/trigger guard in the template The run triggered on a PR Add condition: eq(variables['Build.Reason'],'IndividualCI') or a branch guard
11 Approval never prompts deployment job not bound to an environment The job is a plain job:, not deployment: Use a deployment job with environment:
12 Output of job A unseen by ${{ if }} in stage B Output vars don’t exist at compile time The if is ${{ }} referencing dependencies Use runtime condition: ... $[ dependencies... ]

The ones that bite hardest, expanded:

1. “A template expression is not allowed in this context.” Cause: A compile-time ${{ }} expression where the schema only evaluates at runtime, or a runtime $( ) where the field is read at compile time (like pool: selection driven by a value that should be a parameter). Confirm: Read the offending line and ask: is this value knowable before the run (a parameter, a compile-time variable) or only during it (a step output, a user-set variable)? The phase mismatch is the bug. Fix: Parameter-driven structure → make it a parameter and use ${{ }}. A value produced during the run → $( ) (scripts/inputs) or $[ ] (dependency outputs).

2. A ${{ if }} block silently never inserts. Cause: The condition references a variable that doesn’t exist at compile time (set by a later step or a runtime variables:), so ${{ if }} sees empty. Confirm: In the Preview API finalYaml, the conditional block is simply absent. Check whether you’re testing a parameters.* (compile-time, works) or a variables[...] (often runtime). Fix: Caller choice → make it a parameter and keep ${{ if }}. Genuine runtime value → use a runtime condition: on the step instead.

3. “Template file … not found” on a cross-repo reference. Cause: Referenced a template in another repo without the @alias suffix, or never declared that repo under resources: repositories:. Confirm: Check the - template: path ends with @templatesRepo, and that resources: repositories: has a matching repository: alias. Fix: Add the resources block (alias, name: Project/Repo, ref:), and append @<alias> to every cross-repo path.

5. A consumer broke after the platform team merged to main. Cause: The consumer pinned ref: refs/heads/main, picking up every commit — including half-finished ones. Confirm: The resources block shows ref: refs/heads/main, and the break coincides with a templates-repo merge. Fix: Pin to an immutable tag (refs/tags/vX.Y.Z); reserve main for development and upgrade by bumping the tag.

11. The manual approval never prompts; the deploy just runs. Cause: The deploy job is a plain job:, not a deployment: bound to an environment: — approvals/checks attach to environments, so a non-deployment job has nothing to gate. Confirm: The job key is - job: not - deployment:, or has no environment:; the Environments blade shows no run history. Fix: Make it a - deployment: job with environment: ${{ parameters.environment }} and configure an Approvals check on that environment.

Best practices

Security notes

The security controls and what each prevents:

Control Mechanism Prevents Where to set it
Secrets out of YAML Variable group + Key Vault link Secret leak via repo history Pipelines → Library
Least-privilege deploy identity Scoped service connection + WIF Over-broad blast radius Project settings → Service connections
Library branch policy PR review + required validation Unreviewed change to the golden path Repos → Branches → Policies
Enforced golden path extends + required-template check Teams skipping mandatory gates Environment/repo checks
Prod gate Environment approval + branch control Unapproved/wrong-branch prod deploy Pipelines → Environments → Checks
Reproducible templates Tag / SHA ref pinning Supply-chain drift from main Consumer resources: ref:

Cost & sizing

Templates themselves cost nothing — they are YAML files; the cost is in the runs they produce, identical whether the logic is templated or copy-pasted. So “sizing” is about agent parallelism and agent minutes, plus the indirect savings templates create.

Rough figures (private project, INR/USD approximate, region-dependent):

Cost driver What you pay for Rough figure Templates’ effect Watch-out
Hosted free tier 1 free parallel job, capped monthly minutes ₹0 None on minutes; standardises pool Cap throttles concurrency
Extra Microsoft-hosted parallel job Per concurrent job, flat monthly ~₹3,000–3,500 / job / mo None on price; prevents pricier-pool drift Buy only the concurrency you need
Self-hosted parallel job Per concurrent job (cheaper) + your VM small fee + VM cost One param re-points the whole fleet You patch/run the VMs
Preview API validation Service-side compile ₹0 (no agent minutes) Free pre-merge safety net
Storage account (lab target) Standard_LRS, minutes of life ~₹0 (negligible) Delete the RG after

Interview & exam questions

1. What are the four kinds of Azure DevOps YAML templates and what does each wrap? Step (a steps: fragment — one or more tasks), job (a jobs: fragment — a unit of work with its own pool), stage (a stages: fragment — a full phase like deploy-to-env), and variable (a variables: block). A fifth, the extends template, wraps the entire pipeline shape to enforce structure. Each is inserted only where its node kind is legal.

2. Explain the difference between ${{ }} and $( ). ${{ }} is a template expression evaluated at compile time, operating on parameters and compile-time variables — it can change the pipeline’s structure via if/each insertion. $( ) is a macro variable substituted at runtime as a step runs, operating on string variables whose values may only exist during the run. The rule: ${{ }} decides what the pipeline is; $( ) uses a value while it runs.

3. How do you reuse a template that lives in a different repository? Declare the other repo under resources: repositories: with an alias, name (Project/Repo), and a ref, then reference the template with the @alias suffix (- template: x.yml@myAlias). Without both the resources declaration and the @alias, the compiler looks in the local repo and reports “template not found.”

4. What is a ref and why pin to a tag instead of a branch? The ref selects which version of the template repo a consumer uses. Pinning refs/heads/main means every merge to that branch silently changes your pipeline (and can break it mid-flight). Pinning refs/tags/vX.Y.Z gives an immutable, deliberate, opt-in upgrade — you change one line to adopt a new version. Tags (or a commit SHA) are the production-safe choice.

5. How does an extends template enforce a mandatory step a consumer can’t remove? The consumer’s whole pipeline declares extends: yourTemplate and can only fill the parameters it exposes (e.g. a stepList for build steps). The template sandwiches that list with mandatory steps it controls — scan before, publish after — and the consumer has no syntax to drop them. Backed by a check that runs must extend the named template, it becomes a hard control.

6. What is the difference between conditional insertion (${{ if }}) and a runtime condition:? ${{ if }} is evaluated at compile time and removes the step from the document entirely when false — it never appears in the run. A condition: keeps the step in the document but marks it skipped at runtime when false. Use insertion for parameter-driven structure (cleaner runs); use condition: for decisions only known at runtime.

7. Why might a ${{ if }} block referencing a variable never fire? Because that variable doesn’t exist at compile time${{ }} is evaluated before the run, so a variable set by an earlier step (an output) or a runtime variables: value reads as empty and the block never inserts. If the value is a caller choice, make it a parameter; if it’s genuinely runtime, use a condition: with $[ dependencies… ] instead.

8. What parameter types support injecting a caller’s steps into a fixed template shell? step (one) and stepList (many) for steps; job/jobList, deployment/deploymentList, and stage/stageList for higher levels. The stepList parameter is the core of the extends pattern — the caller passes their steps and the template each-splices them between mandatory gates.

9. How do you validate a templated pipeline without running it? Use the Preview run mode (the runs/preview API with {"previewRun": true}), which expands every template, substitutes parameters, and returns the final composed YAML (or the validation error) with zero agent minutes consumed. It’s the ideal CI gate on the templates repo itself.

10. Where do approvals attach, and how does a stage template wire them? Approvals and checks attach to an environment, not to a pipeline or job directly. A stage template gates production by using a deployment job bound to environment: ${{ parameters.environment }}; you configure a manual-approval check on that environment once, and every stage targeting it inherits the gate.

11. What’s the risk of clientAffinity-style “grant access to all pipelines” on a service connection, and the fix? It lets any pipeline in the project (including a new or compromised one) use that connection’s identity — potentially production credentials. The fix is to authorize each service connection (and variable group) only to the specific pipelines that need it, keeping least privilege and a small blast radius.

12. Why is a template library “the same idea as Bicep modules”? Both invert duplication into a parameterised, versioned, single-source-of-truth unit: a Bicep module is reusable infrastructure with params; a YAML template is a reusable pipeline fragment with parameters. You compose small units, give them typed inputs and defaults, pin versions, and fix bugs once.

These map to AZ-400 (DevOps Engineer Expert)design and implement a pipeline strategy, manage pipeline templates and reuse, implement a release/approval strategy — and touch AZ-104 where service connections and environments intersect with governance. A compact cert mapping:

Question theme Primary cert Objective area
Template types & composition AZ-400 Design & implement pipelines; reuse
${{ }} vs $( ), insertion vs condition AZ-400 Implement pipeline logic
Cross-repo resources & ref pinning AZ-400 Manage pipeline dependencies/versioning
extends & enforced gates AZ-400 Implement approvals/governance
Environments & approvals AZ-400 / AZ-104 Release strategy; secure deployments
Service connections & least privilege AZ-104 / AZ-500 Secure pipeline identities

Quick check

  1. You want a build step removed from the run entirely (not just skipped) when a runTests parameter is false. Which construct do you use, and why is a runtime condition: the wrong choice?
  2. A consumer pipeline references templates/deploy.yml@templatesRepo but gets “template file not found.” Name the two things that must both be present for a cross-repo reference to resolve.
  3. True or false: pinning consumers to refs/heads/main is the recommended way to keep them automatically up to date.
  4. Your ${{ if eq(variables.deployResult, 'succeeded') }} block never inserts, even when the prior job succeeded. Why?
  5. You must guarantee every pipeline runs a security scan that app teams cannot delete. Which template mechanism do you use, and what binds the production approval?

Answers

  1. Use compile-time conditional insertion: ${{ if eq(parameters.runTests, true) }}:. When runTests is false, the step is not emitted into the compiled document at all. A runtime condition: is wrong because it keeps the step in the run and merely marks it skipped — the parameter is known at compile time, so you can remove the step entirely for a cleaner run.
  2. (a) The other repo must be declared under resources: repositories: with a matching alias, name (Project/Repo), and ref; and (b) the template reference must carry the @alias suffix. Missing either makes the compiler look in the local repo and fail.
  3. False. refs/heads/main picks up every merge — including work-in-progress — and can break all consumers at once. Pin to an immutable tag (refs/tags/vX.Y.Z); upgrade deliberately by bumping the tag.
  4. Because ${{ if }} is evaluated at compile time, before any job runs, so a runtime output variable like deployResult doesn’t exist yet and reads as empty. Use a runtime condition: referencing $[ dependencies.<job>.outputs[...] ] instead, or make the gate a parameter.
  5. Use an extends template that sandwiches the caller’s stepList between a mandatory scan (before) and publish (after) — there’s no syntax for the consumer to remove them. The production approval is bound to a protected environment via a deployment job (environment: ...), with a manual-approval check configured on that environment.

Glossary

Next steps

You can now build, version, and enforce a reusable pipeline library. Build outward:

Azure DevOpsYAML PipelinesCI/CDTemplatesDevOpsPipeline as CodeGovernance
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading