Contoso's main branch is already protected by policies, but one of them points at nothing: build validation requires a pipeline to give the go-ahead before merging, and that pipeline does not exist yet. Without it, the other policies check formalities — there are two approvals, there is a linked work item — but nobody has verified the most elementary thing: that the code builds and that the tests pass.

This lesson builds that verifier. Azure Pipelines runs, on every proposed change and every merge, the same repeatable sequence of restore, build, test, analyze and package. The result is an artifact — the exact package that will later be deployed — and a binary verdict within a few minutes. Here we deal only with building and verifying; taking that artifact to Azure is the next lesson.

Contents

  1. What continuous integration is and what problem it removes
  2. Agents, pools and their real costs
  3. YAML pipelines versus classic UI ones
  4. Anatomy of an azure-pipelines.yml, line by line
  5. The essential tasks and dependency caching
  6. Variables, variable groups and Key Vault
  7. Reusable pipeline templates
  8. Pull request validation and job matrices
  9. Artifacts, failure diagnosis and the status badge
  10. Common Mistakes and Tips
  11. Exercises
  12. Conclusion

  1. What continuous integration is and what problem it removes

Continuous integration means that every change is integrated with everybody else's work and verified automatically, several times a day. It is not "having a build server": it is a discipline in which everyone's code is constantly brought together instead of piling up in separate corners.

The problem it removes has a name: integration hell. Diego works for three weeks on the cabin map while a colleague refactors the fare model; each tests on their own laptop and everything works. When it is brought together, conflicts appear, signatures do not match and a bug shows up that nobody can attribute — right on the Friday of the release. The cost of integrating grows non-linearly with how long you take to do it, hence the counterintuitive conclusion: if integrating hurts, do it more often.

Requirement What it means How Contoso meets it
A shared repository A single source of truth Azure Repos (05-02)
Automated build Nobody builds by hand to validate This pipeline
Automated tests The verdict is objective, not an opinion Unit tests with coverage
Fast feedback Under 10 minutes Caching, parallelism, fast tests

The cultural rule that holds it up: if the main build breaks, fixing it is the team's number one priority. A broken build that is tolerated for days turns the pipeline into noise everybody ignores.

  1. Agents, pools and their real costs

An agent is the machine that runs the steps. There are two kinds, and choosing wrong costs money or flat out stops you deploying:

Microsoft-hosted agents Self-hosted agents
Who maintains them Microsoft You
State between runs Always a fresh, clean machine Persists: fast caches, but also junk
Preinstalled software A broad catalog (SDKs, Docker, Node) Whatever you install
Access to vnet-contoso-pro No Yes
Maximum time per job 60 min (free) / 360 min (paid) No limit
Cost Free minutes and then per parallel job The VM plus its maintenance

Real cost warning: a private project gets 1 hosted parallel job with 1,800 free minutes a month. Once those are gone, runs queue until the following month or you have to buy additional parallel jobs (around $40/month each). Public projects get 10 free parallel jobs with unlimited minutes, which makes making a repository public tempting: do not do it to save money. With self-hosted agents the first parallel job is free and the following ones cost around $15/month, but you pay for the virtual machine and its maintenance.

Contoso needs both. The build pipeline (this lesson) runs on hosted agents: it touches nothing private and benefits from always-current images. The deployment pipeline (05-04) that acts against sql-contoso-reservas-pro needs to be inside vnet-contoso-pro, because that server has only been reachable through pe-sql-reservas since we locked it down in 02-05: there you need a self-hosted agent in snet-gestion, grouped into the pool-contoso-privado pool.

# Install a self-hosted agent on a Linux VM inside snet-gestion
mkdir ~/agent && cd ~/agent
curl -O https://vstsagentpackage.azureedge.net/agent/3.243.1/vsts-agent-linux-x64-3.243.1.tar.gz
tar zxvf vsts-agent-linux-x64-3.243.1.tar.gz
./config.sh --unattended --url https://dev.azure.com/contoso-airlines \
  --auth pat --token $AGENT_PAT --acceptTeeEula \
  --pool pool-contoso-privado --agent agente-contoso-01
sudo ./svc.sh install && sudo ./svc.sh start   # As a service, to survive reboots

The agent VM must authenticate against Azure with a managed identity, not with stored credentials: the same principle as module 4.

  1. YAML pipelines versus classic UI ones

YAML Classic (graphical UI)
Where the definition lives In the repository, next to the code In the Azure DevOps database
Versioning and rollback With Git: history and branches Not versioned
Review In the pull request, like code Nobody reviews a click
Reuse Templates Task groups
Status Recommended Legacy, no new features

The decisive argument is the first one: if the pipeline lives in the repository, a change to how you build goes through the same review as a code change, is recorded and can be reverted. With classic, somebody changes a step on a Tuesday afternoon, the build starts behaving differently and there is no history to consult. Contoso uses YAML exclusively.

  1. Anatomy of an azure-pipelines.yml, line by line

This is the contoso-reservas-ci pipeline, at the root of the repository:

# Build number for each run; available as $(Build.BuildNumber).
# Including the version is what will let us version packages in 05-05.
name: 2.5.$(Date:yyyyMMdd)$(Rev:.r)

trigger:                    # Which PUSHES start the pipeline
  branches:
    include: [ main, releases/* ]
  paths:
    exclude: [ docs/*, README.md ]   # Do not burn minutes on documentation changes

pr:                         # Which PULL REQUESTS start it: this is what hooks into
  branches:                 # the build validation policy from 05-02
    include: [ main ]

schedules:                  # Nightly build: catches breakage that does not come
  - cron: "0 2 * * *"       # from your code (an updated dependency, a new image)
    displayName: Nightly build
    branches: { include: [ main ] }
    always: true            # Run even if there have been no changes

pool:
  vmImage: ubuntu-latest    # Microsoft-hosted agent

variables:
  - group: vg-contoso-reservas-comun    # Shared variable group (section 6)
  - name: solutionPath
    value: 'src/Contoso.Reservas.sln'

stages:
  - stage: build
    jobs:
      - job: compile
        timeoutInMinutes: 20            # Cuts the run short if something hangs
        steps:
          - checkout: self
            fetchDepth: 1               # Shallow clone: faster
          - task: UseDotNet@2           # Do not depend on what the image happens to ship
            inputs: { packageType: sdk, version: '8.0.x' }
          - script: dotnet restore $(solutionPath)
            displayName: Restore dependencies
          - script: dotnet build $(solutionPath) -c Release --no-restore -warnaserror
            displayName: Build
          - script: |
              dotnet test $(solutionPath) -c Release --no-build \
                --logger trx --collect:"XPlat Code Coverage" \
                --results-directory $(Agent.TempDirectory)/tests
            displayName: Run unit tests
          - task: PublishTestResults@2
            condition: succeededOrFailed()      # Publish even if they failed:
            inputs:                             # that is when you most want to see them
              testResultsFormat: VSTest
              testResultsFiles: '$(Agent.TempDirectory)/tests/**/*.trx'
              failTaskOnFailedTests: true       # Without this the pipeline stays green
          - task: PublishCodeCoverageResults@2  # even when the tests fail
            condition: succeededOrFailed()
            inputs:
              summaryFileLocation: '$(Agent.TempDirectory)/tests/**/coverage.cobertura.xml'
          - script: |
              dotnet publish src/Contoso.Reservas.Web/Contoso.Reservas.Web.csproj \
                -c Release --no-build -o $(Build.ArtifactStagingDirectory)/web
            displayName: Prepare the deployable package
          - publish: $(Build.ArtifactStagingDirectory)/web
            artifact: web-reservas       # What 05-04 will take as is

On the structure: trigger and pr are the two push triggers (trigger: none disables automatic starting, typical in deployment pipelines). The stages → jobs → steps hierarchy means that a stage is a logical phase and can carry approvals, a job runs in its entirety on one agent and several can run in parallel, and a step is an individual action. The difference between task and script is that a task is a reusable component with named inputs and a version (@2), whereas a script is a shell command: use tasks when they bring something real — publishing results, authenticating — and scripts when the command is clear, because they are more readable and portable. And condition: succeededOrFailed() forces a step to run even if the previous one failed.

  1. The essential tasks and dependency caching

Restore always explicitly, without relying on what "was already" on the machine. Build with -warnaserror, which turns warnings into errors; without it they pile up in the hundreds and stop being read. Test while publishing results and coverage, with failTaskOnFailedTests: true — and beware of turning coverage into a target: demanding 90% produces tests that walk through code without checking anything. Publish the artifact, which enshrines the principle of build once, deploy many times: the binary validated in development is exactly the same, bit for bit, as the one that reaches production; rebuilding per environment introduces differences that are impossible to trace.

What is still missing is static analysis, which looks for bugs and vulnerabilities before they exist at runtime, and caching, which brings restore time down from minutes to seconds:

          # The cache key is derived from the operating system and the hash of the
          # project files: if a dependency changes, the key changes and a stale
          # cache is not reused. A fixed key serves junk forever.
          - task: Cache@2
            inputs:
              key: 'nuget | "$(Agent.OS)" | **/*.csproj'
              restoreKeys: 'nuget | "$(Agent.OS)"'
              path: $(NUGET_PACKAGES)

          - script: |
              dotnet format --verify-no-changes            # Agreed style
              dotnet list package --vulnerable --include-transitive
            displayName: Style and vulnerable dependency analysis

Never cache build outputs, only downloaded dependencies.

  1. Variables, variable groups and Key Vault

Where What for Visibility
variables in the YAML Non-sensitive pipeline values Public in the repository
Variable group in the library Values shared by several pipelines Project-wide, with permissions
Group linked to Key Vault Secrets Never visible; resolved at run time

A secret is never written in the YAML, which is in the repository and in every clone; we already saw in 05-02 what a committed secret costs. The correct way is to link a variable group to kv-contoso-pro:

# A normal group, for non-sensitive values
az pipelines variable-group create --name vg-contoso-reservas-comun \
  --variables entorno=comun proyecto=contoso-reservas centro-coste=CC-1042 \
  --authorize false

# Group linked to kv-contoso-pro: the VALUES stay in the vault and
# the service connection only needs the "Key Vault Secrets User" role
az pipelines variable-group create --name vg-contoso-secretos-pro \
  --variables pasarela-pago-clave="" token-meteo="" --authorize false
variables:
  - group: vg-contoso-secretos-pro     # Its values are read from kv-contoso-pro

steps:
  - script: ./scripts/prueba-integracion.sh
    env:
      TOKEN_METEO: $(token-meteo)      # Secrets are NOT injected into the environment
                                       # automatically: you map them on purpose

Three details that save you grief. Secret variables are not exposed automatically as environment variables; the explicit env: mapping is a deliberate protection. Azure Pipelines masks secrets in the logs, but it is not infallible: if your script prints the value transformed (base64-encoded, for instance), the masking will not recognize it. And --authorize false forces you to authorize which pipelines may use the group, which is least privilege applied to build secrets: the pipeline of a test application has no business being able to read the payment gateway key.

  1. Reusable pipeline templates

Contoso has four repositories that build the same way. Copying the YAML into each one means four places to update when the SDK changes. Templates solve that. In contoso-infra, the file plantillas/compilar-dotnet.yml:

parameters:                       # Typed: if a required one is missing, it will not even start
  - { name: solutionPath, type: string }
  - { name: artifactName, type: string }
  - { name: sdkVersion,   type: string,  default: '8.0.x' }
  - { name: runTests,     type: boolean, default: true }

steps:
  - task: UseDotNet@2
    inputs: { packageType: sdk, version: ${{ parameters.sdkVersion }} }
  - script: dotnet restore ${{ parameters.solutionPath }}
  - script: dotnet build ${{ parameters.solutionPath }} -c Release --no-restore -warnaserror

  # Expansion-time condition: if runTests is false, these steps do not
  # even exist in the generated pipeline
  - ${{ if eq(parameters.runTests, true) }}:
    - script: dotnet test ${{ parameters.solutionPath }} -c Release --no-build --logger trx
    - task: PublishTestResults@2
      condition: succeededOrFailed()
      inputs: { testResultsFormat: VSTest, testResultsFiles: '**/*.trx', failTaskOnFailedTests: true }

  - publish: $(Build.ArtifactStagingDirectory)
    artifact: ${{ parameters.artifactName }}

Usage from the contoso-api-disponibilidad repository:

resources:
  repositories:
    - repository: plantillas
      type: git
      name: contoso-reservas/contoso-infra   # project/repository
      ref: refs/tags/plantillas-v1.2         # PINNED to a tag, not to main

steps:
  - template: plantillas/compilar-dotnet.yml@plantillas
    parameters:
      solutionPath: 'src/Contoso.Api.sln'
      artifactName: 'api-disponibilidad'

The ref pinned to a tag stops a change to the template from breaking all four pipelines at once: the update happens by bumping the tag, deliberately. And there is a stricter variant, extends, in which the child pipeline cannot add arbitrary steps, only fill in the gaps that were designed for it:

extends:
  template: plantillas/pipeline-seguro.yml@plantillas
  parameters:
    buildSteps:
      - script: dotnet build src/Contoso.Reservas.sln -c Release

extends is the mechanism a platform team uses to guarantee that no pipeline can skip the security analysis, however much somebody edits their YAML. It is the delivery equivalent of the policies in 04-06: you do not rely on goodwill, you make it structurally impossible.

  1. Pull request validation and job matrices

With the pr trigger defined and the branch policy from 05-02 pointing at this pipeline, the loop closes:

graph LR
    A["Diego pushes<br/>feature/1842"] --> B["Opens a<br/>pull request"]
    B --> C["pr trigger:<br/>CI runs"]
    C --> D{"Does it build and<br/>do tests pass?"}
    D -->|No| E["Red status:<br/>merge blocked"]
    D -->|Yes| F["Green + 2 approvals<br/>+ linked work item"]
    F --> G["Squash onto main"]
    G --> H["trigger:<br/>CI on main"]
    H --> I["web-reservas artifact<br/>ready for 05-04"]

When you need to verify against several configurations, a matrix generates one job per combination:

  - job: compatibility
    strategy:
      matrix:
        linux_net8:   { agentImage: 'ubuntu-latest',  sdkVersion: '8.0.x' }
        windows_net8: { agentImage: 'windows-latest', sdkVersion: '8.0.x' }
        linux_net9:   { agentImage: 'ubuntu-latest',  sdkVersion: '9.0.x' }
      maxParallel: 2        # Limited by the parallel jobs you have paid for
    pool:
      vmImage: $(agentImage)
    steps:
      - task: UseDotNet@2
        inputs: { packageType: sdk, version: $(sdkVersion) }
      - script: dotnet test src/Contoso.Reservas.sln

maxParallel matters for money: with a single parallel job, a nine-combination matrix does not run nine times faster, it runs in single file and consumes nine times the minutes.

  1. Artifacts, failure diagnosis and the status badge

Pipeline artifacts (publish/download) Build artifacts (PublishBuildArtifacts@1)
Performance Much faster, with deduplication Slow with many files
Storage Optimized Full copy
Status Recommended Legacy
- publish: $(Build.ArtifactStagingDirectory)/web    # Publish in the build stage
  artifact: web-reservas
- download: current                                 # Retrieve it in another stage
  artifact: web-reservas

When a pipeline fails, the diagnostic order that saves hours: (1) read the log of the step that failed, not the summary — the real error is usually 30 lines above the last red line; (2) turn on diagnostic logs by setting the system.debug variable to true on a re-run, which shows variable resolution and real paths, where the problem usually lives; (3) reproduce locally by running exactly the commands in the YAML, because 80% of "pipeline-only" failures are environment differences — a variable that exists on your laptop, a file ignored by .gitignore that is present on your machine, or the casing of a name that matters on Linux and not on Windows; and (4) check permissions if the failure mentions authorization: the service connection or the variable group is probably not authorized for this pipeline.

And the status badge in the README.md, which gives immediate visibility into whether main is healthy:

[![Status](https://dev.azure.com/contoso-airlines/contoso-reservas/_apis/build/status/contoso-reservas-ci?branchName=main)](https://dev.azure.com/contoso-airlines/contoso-reservas/_build/latest?definitionId=12&branchName=main)

Common Mistakes and Tips

  • Building in every environment. It breaks the build once, deploy many times principle: the production binary is no longer the one that was tested. Build once, publish the artifact and reuse it.
  • Writing secrets in the YAML. They are in the repository and in every clone. A variable group linked to kv-contoso-pro, always.
  • 40-minute pipelines. If the verdict takes longer than a coffee, people stop waiting for it and carry on working on unverified code. Split, cache and parallelize until you are under 10 minutes.
  • Tolerating a broken build on main, or living with flaky tests. As soon as red is normal, the pipeline stops meaning anything and the team learns to re-run without looking.
  • continueOnError: true for convenience. It turns a step into decoration. Use it only while you tune the threshold of a new tool, and with an expiry date.
  • Templates pointing at main. One change breaks every pipeline simultaneously. Pin to a tag and version your templates.
  • Tip: set timeoutInMinutes on every job. A hung job burns billable minutes up to the 60 or 360 limit.
  • Tip: exclude docs/* and README.md from the trigger. It is one minute of work that saves agent minutes every month.

Exercises

Exercise 1: read a pipeline and spot its problems

A colleague proposes this pipeline for Contoso Miles:

trigger:
  branches: { include: ['*'] }
pool: { vmImage: ubuntu-latest }
variables:
  apiKey: 'ak_millas_9f2b7c1d4e'
steps:
  - script: dotnet build src/Millas.sln
  - script: dotnet test src/Millas.sln
    continueOnError: true
  - script: dotnet publish src/Millas.Web -o output
  1. Identify at least five problems.
  2. List the specific changes you would make to fix them.
  3. Which one is a security incident, and what has to be done beyond fixing the file?

Exercise 2: agents and cost

Contoso adds three pipelines: one builds the website, another the API and another runs integration tests against sql-contoso-reservas-dev, which has a private endpoint. All three run about 20 times a day, 12 minutes each.

  1. What kind of agent does each pipeline need, and why?
  2. Estimate the monthly hosted-minute consumption and say whether it fits in the free tier.
  3. Propose two ways of cutting the spend without giving up verification.

Exercise 3: designing for reuse

The four repositories build .NET almost identically, but contoso-infra has no unit tests (it is Bicep templates) and contoso-modelos publishes a package instead of a web application. Security requires that no pipeline be able to skip the vulnerable dependency analysis.

  1. Would you use template or extends? Justify it against the security requirement.
  2. Sketch out the template's parameters.
  3. How do you stop updating it from breaking all four pipelines at once?

Solutions

Solution 1:

  1. (a) A secret in the YAML: apiKey is in the repository. (b) trigger on all branches, which builds every working branch and burns minutes. (c) The pr trigger is missing, so the build validation policy has nothing to hook into. (d) continueOnError: true on the tests: the pipeline passes even when they fail. (e) Neither results nor coverage are published. (f) No artifact is published at all: there is nothing to deploy afterwards. (g) UseDotNet pinning the SDK version is missing, so it depends on whatever the agent image happens to ship. (h) No timeoutInMinutes and no readable displayName values.
  2. trigger limited to main plus pr: [main]; UseDotNet@2 with 8.0.x; an explicit dotnet restore; build with -warnaserror; dotnet test with --logger trx --collect:"XPlat Code Coverage"; PublishTestResults@2 with failTaskOnFailedTests: true and condition: succeededOrFailed(); coverage publishing; dotnet publish to $(Build.ArtifactStagingDirectory) followed by - publish: with an artifact name; the key replaced by a variable group linked to kv-contoso-pro and mapped with env:; and timeoutInMinutes: 20.
  3. The secret. Beyond taking it out of the file it has to be rotated: it is in the Git history and in the logs of every previous run. Then store the new value in the vault and check whether it was misused. Just as in 05-02, deleting is not enough.

Solution 2:

  1. Website and API, hosted agents: they need no access to anything private and benefit from a clean, maintained machine. The integration tests, a self-hosted agent in pool-contoso-privado inside snet-gestion, because sql-contoso-reservas-dev is only reachable through its private endpoint and a hosted agent has no route to it. The alternative would be managed scale set agents inside the virtual network.
  2. The two hosted pipelines add up to 2 × 20 × 12 = 480 minutes a day, roughly 10,500 a month over 22 working days. The free tier is 1,800, so it is far exceeded; and with a single parallel job the runs would also queue up. You would have to buy additional parallel jobs (~$40/month each).
  3. (a) Dependency caching and a shallow clone, to halve those 12 minutes. (b) Exclude irrelevant paths from the trigger and do not build on every push to working branches, but on the pull request. (c) Reserve the full matrix and the slow tests for the nightly build, leaving only the essentials in the fast loop. (d) Compare the cost of a self-hosted agent VM with that of the parallel jobs, which at this volume may well favor the VM.

Solution 3:

  1. extends. With template the child pipeline includes the template but can add steps, reorder them or simply not call it; with extends it is obliged to build on top of it and can only fill in the gaps designed for it, so the dependency analysis cannot be skipped by editing the YAML. It is the difference between a recommendation and a rule, the same logic as the policies in 04-06.
  2. solutionPath (string, required), artifactName (string), sdkVersion (string, default 8.0.x), runTests (boolean, false for contoso-infra), outputType (web or package, to distinguish contoso-modelos) and additionalSteps (stepList) as the only extension point.
  3. By pinning the template repository reference to a tag (ref: refs/tags/plantillas-v1.2) and versioning the templates with semantic versioning. Each repository bumps its tag when it suits, so a breaking change is tried out in one before it spreads. The template lives in contoso-infra, with its branch policies and the mandatory review by Contoso-Infraestructura.

Conclusion

Contoso's main branch is no longer merely protected: it is verified. You understand continuous integration as a discipline — integrate often because integrating late hurts non-linearly — with its four requirements and the cultural rule that holds it up: a broken build is priority number one. You know how to choose between hosted agents, clean and maintained by Microsoft, and self-hosted agents, essential when you have to reach resources that only exist behind a private endpoint inside vnet-contoso-pro; and you know the real numbers: 1,800 minutes and one parallel job free per private project, and about $40 a month for each additional parallel job.

You have written the contoso-reservas-ci azure-pipelines.yml understanding every piece: trigger with path exclusions, pr to hook into the build validation policy from 05-02, schedules for the nightly build that catches what breaks without you touching anything, and the stages → jobs → steps hierarchy with the difference between task and script. Inside it you have put the essentials — restore, build with -warnaserror, test while publishing results and coverage with failTaskOnFailedTests, analyze vulnerable dependencies and publish the artifact — you have sped it up with a cache whose key is derived from the manifest, and you have made it reusable with templates, telling template from extends: only extends turns the security analysis into something no team can skip. And you have kept module 4's rule intact: no secrets in the YAML, all of them in a variable group linked to kv-contoso-pro, mapped explicitly and authorized only to the pipelines that need them.

The result of all this is a file: the web-reservas artifact, built once, tested and waiting. But it is still waiting: nobody has taken it to app-contoso-reservas-pro, and the Friday night deployment carries on exactly as before. In the next lesson, Continuous Deployment with Environments and Approvals, that artifact will travel. You will create the desarrollo, preproduccion and produccion environments with their record of which version is in each one, you will add Marta Ríos' manual approval and the windows that prevent deploying on a Friday afternoon, you will compare the deployment strategies, and you will run Contoso's real blue-green deployment with the preproduccion slot swap you already know from module 2, with backward-compatible database migrations and a rollback that genuinely works.

Azure Course

Module 1: Introduction to Azure

Module 2: Core Azure Services

Module 3: Azure Databases

Module 4: Security in Azure

Module 5: Azure DevOps

Module 6: Advanced Azure Services

Module 7: Monitoring and Management

Module 8: Cost Management and Optimization

Module 9: Case Studies and Best Practices

© Copyright 2026. All rights reserved