Multi-Stage CI/CD Pipeline in Azure DevOps

Build a multi-stage CI/CD pipeline that builds, lints, and tests Python code.

Introduction

30 Second Summary

When you push code to a shared repository, how do you know it actually works? In production teams, every code change passes through an automated gauntlet of checks before anyone can deploy it.

In this project, you will build a multi-stage CI/CD pipeline in Azure DevOps that automatically builds, lints, and tests a Python application every time you push code to GitHub.

What You'll Build

You will push a code change, switch to Azure DevOps, and watch three pipeline stages light up green one by one as your code passes each quality gate.

By the end of this project, you'll have:

  • A Python calculator app whose tests run automatically in the cloud every time you push a commit.
  • A multi-stage pipeline with three visual stages (Build, Lint, Test) that shows you exactly where a failure happened at a glance.
  • End-to-end traceability between your Azure Boards work items and GitHub commits, so you can trace any code change back to the task that planned it.
  • Secret Mission: Add a deployment stage with approval gates to simulate a production release workflow.

Are there any prerequisites?

None. This guide walks you through setting up every tool you need, including your Azure account, Azure DevOps organization, and GitHub repository.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

You're going to build a multi-stage CI/CD pipeline in Azure DevOps that automatically builds, lints, and tests a Python application every time you push code.

Set Up Azure DevOps

Before you can automate anything with pipelines, you need the platform where those pipelines will live. Azure DevOps brings together pipelines, code repositories, and work tracking in one place.

In this step, you will create your Azure DevOps organization and project. This is the foundation that every subsequent step connects to.

In this step, get ready to:
  • Create an Azure pay-as-you-go account.
  • Create an Azure DevOps organization.
  • Create your first project in Azure DevOps.
Create an Azure pay-as-you-go account

Azure DevOps requires an active Azure subscription to create new organizations. You will use a pay-as-you-go subscription, which costs nothing as long as you stay within the free tier.

✔️ I already have an Azure pay-as-you-go subscription

You are ready to move on to creating your organization. Skip ahead to the next substep.

ⓧ I don't have an Azure account yet

  • Go to azure.microsoft.com/products/devops and select Get started with Azure.
  • Sign in with your Microsoft account. If you do not have one, select the option to create a new Microsoft account.
  • When prompted to choose a plan, select Pay as you go and complete the sign-up process.

Why pay-as-you-go instead of free trial?

Azure DevOps does not support the Azure Free Trial. The pay-as-you-go subscription is required, but you will not be charged for Azure DevOps free tier usage. The free tier includes 1,800 pipeline minutes per month, which is far more than this project needs.

Having trouble signing up?

Make sure you are using a supported browser (Edge, Chrome, Firefox, or Safari). If you already have a Microsoft account through work or school, you can use that instead of creating a new one.

help me sign up for an Azure pay-as-you-go subscription

Create an Azure DevOps organization

An organization is the top-level container in Azure DevOps. It holds all your projects, pipelines, and repos.

  • In the Azure portal, select the app launcher (grid icon in the top-left corner), then select DevOps.
  • Select Create new organization.
  • Enter a name for your organization. Use only letters, numbers, or hyphens, and keep it under 50 characters.
  • Select a hosting geography near you from the dropdown.
  • Select your Azure pay-as-you-go subscription from the billing dropdown.
  • Select Continue.

After a few seconds, Azure DevOps creates your organization. You can access it at any time using the URL below, substituting your chosen name:

https://dev.azure.com/[[YOURORGNAME="your organization name"]]

What is an Azure DevOps organization?

An organization groups related projects together under one roof. It is where you manage users, billing, and security policies. Think of it as your team's workspace for everything DevOps.

Don't see the DevOps option in the app launcher?

If DevOps does not appear in the grid menu, navigate directly to dev.azure.com in your browser and sign in with the same Microsoft account you used for the Azure subscription.

help me find and access Azure DevOps from the Azure portal

Create a new project

A project is where your code, pipelines, and work items live. You will create one now to hold your Python pipeline.

  • On the organization home page, select New project.
  • In the Project name field, enter python-pipeline-demo.
  • Set Visibility to Private.
  • Expand Advanced to reveal additional settings.
  • Under Version control, select Git.
  • Under Work item process, select Agile.
  • Select Create.

Azure DevOps creates your project and displays the project welcome page.

Why Agile for the work item process?

The Agile process template gives you User Stories, Tasks, and Bugs as work item types. This matches the workflow you will use in Step 5 when you create a User Story and link it to a commit.

Before you check the verification below, think about what you expect to see when you navigate to your organization's home page. Will the project appear immediately?

  • Navigate to your organization URL by entering it in your browser's address bar:
https://dev.azure.com/[[YOURORGNAME="your organization name"]]
  • Confirm that python-pipeline-demo appears in the project list on the organization home page.

Don't see your project listed?

Make sure you are signed in with the same account you used to create the organization. If you just created the project, try refreshing the page.

help me find my Azure DevOps project on the organization home page

Your Azure DevOps workspace is ready. Next up, you will create a Python application and run your first pipeline.

Create a Python App and Run Your First Pipeline

Your Azure DevOps organization and project are ready. Now you need something to automate: a real codebase with tests that a pipeline can run on every push.

In this step, you'll create a Python calculator app with a test suite, push it to GitHub, and wire up your first Azure DevOps pipeline. By the end, you'll see your tests run automatically in the cloud.

In this step, get ready to:
  • Create a Python calculator app with test coverage.
  • Push the code to a GitHub repository.
  • Connect GitHub to Azure DevOps and run your first pipeline.
Create your Python app locally

Every pipeline needs a codebase to validate. You'll build a small calculator module with a matching test suite that pytest can run.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder, subdirectories, and empty files by running these commands:
mkdir -p python-pipeline-demo/app python-pipeline-demo/tests
cd python-pipeline-demo
touch app/__init__.py tests/__init__.py
  • Confirm the structure was created by running:
ls app/ tests/

You should see __init__.py listed under both app/ and tests/.

Don't see the files?

Make sure you ran the mkdir -p command first. The -p flag creates parent directories as needed.

help me debug my directory structure

  • Open the python-pipeline-demo folder in VS Code by running:
code .
  • In VS Code, open app/calculator.py (create a new file in the app folder) and paste the following code:
import os

def add(a, b):
    return a + b


def subtract(a, b):
    return a - b


def multiply(a, b):
    return a * b


# Prevent division by zero with a clear error
def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

What does this code do?

  • Four arithmetic functions (add, subtract, multiply, divide) form the application logic your pipeline will validate.
  • The divide function raises a ValueError when b is zero, giving your tests something meaningful to verify.
  • The import os at the top is not used by any function. Leave it there for now.
  • Save app/calculator.py.
  • Now create tests/test_calculator.py in the tests folder and paste the following test suite:
import pytest
from app.calculator import add, subtract, multiply, divide


def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0


def test_subtract():
    assert subtract(5, 3) == 2
    assert subtract(0, 5) == -5


def test_multiply():
    assert multiply(3, 4) == 12
    assert multiply(-2, 3) == -6


def test_divide():
    assert divide(10, 2) == 5.0
    assert divide(7, 2) == 3.5


# Verify the error handling works correctly
def test_divide_by_zero():
    with pytest.raises(ValueError):
        divide(1, 0)

What does this code do?

  • Each test_ function exercises one calculator function with known inputs and expected outputs.
  • The test_divide_by_zero test uses pytest.raises to confirm that dividing by zero raises the expected ValueError.
  • pytest discovers test files automatically by matching the test_ prefix.
  • Save tests/test_calculator.py.
  • Run your tests locally to confirm everything works by running this in your terminal:
pip install pytest
pytest tests/

You should see output showing 5 tests passed with green text.

Tests failing or command not found?

If you see ModuleNotFoundError, make sure you are running pytest from the python-pipeline-demo folder (not from inside app/ or tests/).

If pytest is not found, run pip install pytest first.

help me fix my failing pytest tests

  • Create one more file: requirements.txt in the project root. Paste this single line:
# Runtime dependencies (none for this application)
  • Save requirements.txt.

Why is requirements.txt empty?

This calculator has no external runtime dependencies. The file still exists because it is a standard convention. Your pipeline will install test tools (like pytest) separately.

✔️ Awesome, I've got everything!

Great. Make sure all files are saved in VS Code before continuing.

ⓧ I'd like to double check the full code

Here is app/calculator.py:

import os

def add(a, b):
    return a + b


def subtract(a, b):
    return a - b


def multiply(a, b):
    return a * b


def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

Here is tests/test_calculator.py:

import pytest
from app.calculator import add, subtract, multiply, divide


def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0


def test_subtract():
    assert subtract(5, 3) == 2
    assert subtract(0, 5) == -5


def test_multiply():
    assert multiply(3, 4) == 12
    assert multiply(-2, 3) == -6


def test_divide():
    assert divide(10, 2) == 5.0
    assert divide(7, 2) == 3.5


def test_divide_by_zero():
    with pytest.raises(ValueError):
        divide(1, 0)

Here is requirements.txt:

# Runtime dependencies (none for this application)
Push your code to GitHub

Azure DevOps Pipelines will pull your code from a GitHub repository. You need to push your local project there so the pipeline can access it.

Need a GitHub account?

If you don't have one yet, go to github.com and sign up for a free account. Then return here.

  • Go to github.com and sign in.
  • In the upper-right corner, click the + menu, then click New repository.
  • In the Repository name field, type python-pipeline-demo.
  • Leave visibility as Public.
  • Do NOT check Add a README file (you already have local files to push).
  • Click Create repository.

GitHub shows a Quick Setup page with push instructions. You'll use those now.

  • Back in your terminal (inside the python-pipeline-demo folder), initialize a git repository and push by running these commands:
git init
git add .
git commit -m "Initial commit"
git branch -M main
git remote add origin https://github.com/[[GITHUB_USERNAME="your-github-username"]]/python-pipeline-demo.git
git push -u origin main

What do these commands do?

  • git init turns your folder into a Git repository.
  • git add . stages all your files for the first commit.
  • git branch -M main renames the default branch to main (matching the pipeline trigger you'll configure next).
  • git push -u origin main uploads everything to your GitHub repository.
  • Refresh your GitHub repository page in the browser.

You should see your app/, tests/, and requirements.txt files listed.

Push failed or authentication error?

GitHub requires a personal access token or SSH key for authentication. If you see a password prompt or 403 error, follow GitHub's guide to create a personal access token and use it as your password.

help me push my code to GitHub

Connect GitHub to Azure DevOps and create your first pipeline

Now that your code lives on GitHub, you'll connect it to Azure DevOps and create a pipeline that runs your tests automatically on every push.

  • Return to your Azure DevOps project at dev.azure.com.
  • In the left sidebar, select Pipelines.
  • Click New pipeline (or Create Pipeline if this is your first).
  • On the "Where is your code?" screen, select GitHub.

What happens when you select GitHub?

Azure DevOps will ask you to authorize the connection via OAuth. A GitHub authorization page opens in your browser. Click Authorize AzurePipelines to allow Azure DevOps to access your repositories.

  • Authorize the GitHub connection when prompted.
  • Select your python-pipeline-demo repository from the list.
  • On the Configure screen, select Starter pipeline.

Azure DevOps opens a YAML editor with a default template. You'll replace it with your own pipeline configuration.

  • Select all the existing YAML content and replace it with the following:
trigger:
- main

pool:
  vmImage: 'ubuntu-latest'

jobs:
- job: InstallAndTest
  displayName: 'Install and Test'
  steps:
  # Select Python 3.12 on the hosted agent
  - task: UsePythonVersion@0
    inputs:
      versionSpec: '3.12'
    displayName: 'Use Python 3.12'
  # Install test dependencies
  - script: |
      python -m pip install --upgrade pip
      pip install pytest
    displayName: 'Install dependencies'
  # Run the test suite
  - script: |
      pytest tests/
    displayName: 'Run pytest'

What does this pipeline do?

  • trigger: - main tells Azure DevOps to run this pipeline every time you push to the main branch.
  • vmImage: 'ubuntu-latest' runs the pipeline on a Microsoft-hosted agent (a fresh Linux VM in the cloud).
  • UsePythonVersion@0 selects Python 3.12 on the agent.
  • The two script steps install pytest and then run your test suite.

Before you run this pipeline, what do you think will happen? Will all your tests pass on the cloud agent the same way they passed locally?

  • Click Save and run.
  • In the dialog that appears, keep the default commit message and branch (main). Click Save and run again.

What just happened?

Azure DevOps committed the azure-pipelines.yml file directly to your GitHub repository and immediately triggered the first pipeline run.

The pipeline page opens and shows your run in progress. Wait for it to complete (about 30-60 seconds).

You should see a green checkmark next to the pipeline run. Click into the run, then click the Install and Test job. You can expand each step to see the console output from the cloud agent, including your 5 passing tests.

Pipeline stuck or showing a permissions error?

New Azure DevOps organizations sometimes require you to request free pipeline parallelism. If you see a message about needing approval, follow the link in the Azure DevOps notification to request free tier access. It typically takes a few minutes.

If the pipeline fails with a Python version error, check that you used versionSpec: '3.12' (with quotes around the version number) in your YAML.

help me fix my Azure DevOps pipeline

Your first pipeline is running. Every time you push code to main, Azure DevOps will automatically install your dependencies and run your tests in the cloud. Next up, you'll add a linting stage and discover why a single flat pipeline makes debugging harder than it needs to be.

Add Linting and Hit the Single-Stage Wall

Your pipeline runs tests, but it does not enforce code quality. In a real team, messy code (unused imports, bad formatting) slips into the codebase silently unless a linter catches it automatically.

In this step you will add flake8 linting to your pipeline so it scans every push for code quality issues. But you are about to discover a real problem: when everything lives in one undifferentiated stage, figuring out what broke requires digging through logs manually.

In this step, get ready to:
  • Add flake8 linting to your existing single-stage pipeline.
  • Watch the pipeline fail due to a lint violation.
  • Experience the difficulty of diagnosing failures in a flat pipeline.
Add flake8 linting to your pipeline

Linting is an automated code quality scan that catches common mistakes like unused imports, inconsistent spacing, and overly complex functions. You will use flake8, a popular Python linter, to enforce these rules on every pipeline run.

  • In VS Code, open azure-pipelines.yml from the root of your python-pipeline-demo folder.
  • Find the install step that currently looks like this:
  - script: |
      python -m pip install --upgrade pip
      pip install pytest
    displayName: 'Install dependencies'
  • Replace pip install pytest with pip install pytest flake8 so the install step now reads:
  - script: |
      python -m pip install --upgrade pip
      pip install pytest flake8
    displayName: 'Install dependencies'

Now you need to add the step that actually runs the linter.

  • Directly below the Install dependencies step and above the Run pytest step, add this new script step:
  - script: |
      flake8 app/ tests/
    displayName: 'Run flake8 linting'

What does this change do?

You made two changes to the pipeline. First, you added flake8 to the install step so it is available on the agent.

Second, you inserted a new script step that runs flake8 app/ tests/ to scan both the application code and tests for style violations. If flake8 finds any violation, it exits with a non-zero code and the pipeline step fails.

  • Save azure-pipelines.yml.

✔️ Awesome, I've got everything!

Great. Make sure you have saved azure-pipelines.yml before continuing.

ⓧ I'd like to double check the full code

Your complete azure-pipelines.yml should look exactly like this:

trigger:
- main

pool:
  vmImage: 'ubuntu-latest'

jobs:
- job: InstallAndTest
  displayName: 'Install and Test'
  steps:
  - task: UsePythonVersion@0
    inputs:
      versionSpec: '3.12'
    displayName: 'Use Python 3.12'
  - script: |
      python -m pip install --upgrade pip
      pip install pytest flake8
    displayName: 'Install dependencies'
  - script: |
      flake8 app/ tests/
    displayName: 'Run flake8 linting'
  - script: |
      pytest tests/
    displayName: 'Run pytest'
  • Commit and push the change by running these commands in your terminal:
git add azure-pipelines.yml
git commit -m "Add flake8 linting to pipeline"
git push

Push rejected or authentication error?

Make sure you are inside the python-pipeline-demo directory when you run the commands. If GitHub asks for credentials, confirm that your SSH key or personal access token is configured correctly.

Help me troubleshoot my git push error

Watch the pipeline fail

Your push to main triggers the pipeline automatically. Before you check the result, think about what you expect to happen.

Do you think this pipeline run will pass or fail? Remember that app/calculator.py contains import os at the top, and that module is never used anywhere in the file.

  • In Azure DevOps, navigate to Pipelines in the left sidebar.
  • Click on your pipeline to see the latest run.
  • Wait for the run to complete (this takes about a minute).

The pipeline shows a red X on the single job. The flake8 linter caught the unused import os in calculator.py and reported an F401 error: 'os' imported but unused. Because flake8 exited with a non-zero code, the pipeline failed before it ever reached pytest.

What is an F401 error?

F401 is flake8's code for an unused import. It means you imported a module that is never referenced in the file. Unused imports add clutter and can mask real dependency issues in larger projects.

Experience the single-stage problem

Now look at the pipeline summary. From this view, you see one red job called "Install and Test" with no immediate indication of what broke.

  • Click on the failed job to expand it.
  • Notice there are multiple step outputs listed: Use Python 3.12, Install dependencies, Run flake8 linting, and Run pytest.
  • Expand the Run flake8 linting step to find the F401 error message in the logs.

You had to click into the job, scroll through multiple steps, and read the output to identify that flake8 caused the failure. From the pipeline summary alone, there was no at-a-glance separation between a build failure, a lint failure, and a test failure.

Why is this a problem?

In a production pipeline with dozens of steps, this flat structure means every failure looks the same from the outside. A teammate glancing at the pipeline has no way to tell whether the install broke, the linter found dirty code, or the tests failed.

The fix is to separate these responsibilities into distinct stages so each one passes or fails independently. That is exactly what you will do in the next step.

You have felt the pain of a flat pipeline firsthand. Next up, you will refactor this into distinct stages so failures announce themselves at a glance.

Refactor to a Multi-Stage Pipeline

In the previous step, you felt the pain of a flat, single-stage pipeline. One red X, and you had to click through logs to figure out whether it was the install, the linting, or the tests that broke.

Multi-stage pipelines solve this by separating concerns into distinct, visible gates. Each stage runs independently and reports its own pass or fail status directly in the Azure DevOps pipeline visualization. In this step, you will refactor your pipeline into three stages and immediately see the difference.

In this step, get ready to:
  • Refactor your pipeline YAML into Build, Lint, and Test stages.
  • See the multi-stage failure pattern in action.
  • Fix the lint error and confirm all three stages pass green.
Refactor your YAML into three stages

You are going to replace your entire single-stage pipeline with a multi-stage version that uses the stages keyword. Each stage installs its own tools because Microsoft-hosted agents are ephemeral. Nothing persists between stages.

  • Open azure-pipelines.yml in your editor.
  • Select all the existing content and delete it.
  • Paste the following code to define the pipeline trigger, pool, and the Build stage:
trigger:
- main

pool:
  vmImage: 'ubuntu-latest'

stages:
- stage: Build
  displayName: 'Build Stage'
  jobs:
  - job: BuildJob
    displayName: 'Install Dependencies'
    steps:
    - task: UsePythonVersion@0
      inputs:
        versionSpec: '3.12'
      displayName: 'Use Python 3.12'
    - script: |
        python -m pip install --upgrade pip
        pip install pytest flake8
      displayName: 'Install dependencies'
    - script: |
        python -c "import pytest; print('pytest version:', pytest.__version__)"
        python -c "import flake8; print('flake8 available')"
      displayName: 'Verify installations'

What does this code do?

The trigger and pool sections work the same as before. The new stages: keyword tells Azure Pipelines to treat each entry as a separate, independently reported stage.

The Build stage installs Python 3.12, upgrades pip, and installs both pytest and flake8. A verification step confirms the tools installed correctly.

  • Below the Build stage you just pasted, add the Lint stage:

- stage: Lint
  displayName: 'Lint Stage'
  dependsOn: Build
  jobs:
  - job: LintJob
    displayName: 'Run Linting'
    steps:
    - task: UsePythonVersion@0
      inputs:
        versionSpec: '3.12'
      displayName: 'Use Python 3.12'
    - script: |
        python -m pip install flake8
        flake8 app/ tests/
      displayName: 'Run flake8 linting'

What does dependsOn do?

The dependsOn: Build line means the Lint stage only runs if the Build stage succeeds. If Build fails, Lint is skipped. This creates a clear chain of gates.

Because each stage uses a fresh agent, the Lint stage installs flake8 independently. Nothing carries over from the Build stage.

  • Below the Lint stage, add the Test stage:

- stage: Test
  displayName: 'Test Stage'
  dependsOn: Lint
  jobs:
  - job: TestJob
    displayName: 'Run Tests'
    steps:
    - task: UsePythonVersion@0
      inputs:
        versionSpec: '3.12'
      displayName: 'Use Python 3.12'
    - script: |
        python -m pip install pytest pytest-azurepipelines
        pytest tests/ --junitxml=junit/test-results.xml
      displayName: 'Run pytest'
    - task: PublishTestResults@2
      condition: succeededOrFailed()
      inputs:
        testResultsFiles: '**/test-results.xml'
        testRunTitle: 'Python Tests'
      displayName: 'Publish test results'

What does the Test stage do?

The Test stage depends on Lint. It installs pytest and pytest-azurepipelines, runs your tests, and outputs results in JUnit XML format.

The PublishTestResults@2 task uploads those results directly to Azure DevOps. The condition: succeededOrFailed() ensures results are published even if some tests fail, so you always see the full picture.

  • Save azure-pipelines.yml.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before continuing.

ⓧ I'd like to double check the full code

Your azure-pipelines.yml should contain three sections in order. Scroll up and confirm each chunk is present:

  • The trigger/pool header and Build stage (starting with trigger: and ending with displayName: 'Verify installations').
  • The Lint stage (starting with - stage: Lint and ending with displayName: 'Run flake8 linting').
  • The Test stage (starting with - stage: Test and ending with displayName: 'Publish test results').
  • Commit and push your changes by running these commands:
git add azure-pipelines.yml
git commit -m "Refactor to multi-stage pipeline"
git push

Push rejected or authentication error?

Make sure you are on the main branch. Run git branch to check.

If you see a credentials error, re-authenticate with GitHub using your personal access token or SSH key. Help me fix my git push authentication error

Observe the multi-stage failure

The unused import os is still in app/calculator.py. Before you check the pipeline, think about what you expect to happen.

Do you think all three stages will pass, or will one fail? If one fails, which one, and what happens to the stages that come after it?

  • Switch back to Azure DevOps in your browser.
  • Select Pipelines in the left sidebar.
  • Click on the most recent pipeline run that was triggered by your push.

You now see three distinct stages in the pipeline visualization instead of a single job. Build shows a green checkmark. Lint shows a red X. Test shows as skipped because it depends on Lint succeeding.

Why is this better than a single stage?

In Step 3, you had one red job and no indication of what broke without opening logs. Now you can see at a glance that the Build stage passed and the Lint stage is the problem. The Test stage was skipped because it depends on Lint.

In production pipelines with dozens of steps, this visual separation saves significant debugging time.

Fix the lint error and see all stages pass

You know exactly what is wrong. The Lint stage caught the unused import os in app/calculator.py. Time to fix it.

  • Open app/calculator.py in your editor.
  • Find this line at the top of the file:
import os
  • Delete the import os line entirely. The file should now start directly with def add(a, b):.
  • Save app/calculator.py.
  • Commit and push the fix by running these commands:
git add app/calculator.py
git commit -m "Remove unused import os"
git push

Before you check the pipeline results, what do you expect to see this time? Will all three stages pass now that the lint error is gone?

  • Return to Pipelines in Azure DevOps and click on the new pipeline run.

All three stages now show green checkmarks. Build passed, Lint passed, and Test passed.

  • Click into the Test Stage to expand it.
  • Select the Tests tab in the pipeline run summary.

You can see your published test results showing pass/fail counts for each test function. This is the PublishTestResults@2 task at work, surfacing test data directly in Azure DevOps instead of buried in console logs.

Pipeline still failing?

If Lint still fails, confirm you removed the entire import os line (not just the word os). The file should begin with def add(a, b): on the first line.

If you see a YAML syntax error, check that your indentation uses spaces (not tabs) and that each stage entry is aligned under stages:.

Help me debug my multi-stage pipeline

You now have a production-style multi-stage pipeline where each gate is visible and independent. Next up, you will connect Azure Boards to track your work and link code changes directly to planned work items.

Connect Azure Boards for End-to-End Traceability

Your pipeline now automatically validates every code change across three clear stages. That is powerful, but it is only half the DevOps story.

Production teams need to trace every code change back to a planned work item. In this step, you will connect Azure Boards to your GitHub commits, creating the audit trail that team leads and compliance processes require.

In this step, get ready to:
  • Create a User Story work item in Azure Boards.
  • Link a code change to the work item using the AB# syntax.
  • Verify end-to-end traceability from plan to code to pipeline.
Create a work item in Azure Boards

Work items are how teams plan and track what needs to be built. You will create a User Story that describes the feature you are about to implement.

  • In your Azure DevOps project, select Boards in the left sidebar.
  • Select Work Items.
  • Select New Work Item and choose User Story from the dropdown menu.
  • In the Title field, enter Add percentage calculation to calculator.
  • In the Description field, enter a brief description such as Add a percentage function that calculates a percentage of a given value.
  • Select Save.
  • Note the work item ID displayed at the top of the form (e.g., #1). You will use this number in your commit message.

What is a work item?

A work item in Azure Boards represents a unit of planned work. User Stories describe features from the user's perspective. Each work item gets a unique ID that you can reference from code commits, pull requests, and pipelines to create a traceable development history.

Don't see the User Story option?

Make sure your project uses the Agile process. You selected this when creating the project in Step 1. If you chose a different process, the work item type might be called Issue (Basic), Product Backlog Item (Scrum), or Requirement (CMMI).

help me find the User Story work item type in my Azure DevOps project

Add a percentage function linked to the work item

Now you will write the actual code change that this work item describes. You will add a percentage function to your calculator and a corresponding test.

  • In VS Code, open app/calculator.py.
  • Find the divide function at the bottom of the file.
  • Add the following percentage function below it:
def percentage(value, percent):
    return value * (percent / 100)

What does this function do?

The percentage function takes a value and a percent, then returns the calculated percentage of that value. For example, percentage(200, 10) returns 20.0.

  • Save app/calculator.py.
  • Now open tests/test_calculator.py.
  • Find the import line at the top of the file:
from app.calculator import add, subtract, multiply, divide
  • Replace it with this updated import that includes percentage:
from app.calculator import add, subtract, multiply, divide, percentage
  • Scroll to the bottom of the file, below the test_divide_by_zero function.
  • Add the following test function:
def test_percentage():
    assert percentage(200, 10) == 20.0
    assert percentage(50, 50) == 25.0

What does this test do?

The test_percentage function verifies that percentage(200, 10) returns 20.0 (10% of 200) and percentage(50, 50) returns 25.0 (50% of 50). These assertions confirm the function calculates correctly.

  • Save tests/test_calculator.py.
  • Verify your new code works locally by running the tests:
pytest tests/
  • You should see all 6 tests pass, including the new test_percentage.

Tests failing locally?

If test_percentage fails with an ImportError, check that you updated the import line at the top of tests/test_calculator.py to include percentage. Also confirm you added the percentage function to app/calculator.py and saved both files.

help me debug why my pytest is failing after adding the percentage function

Now commit your changes with a message that links to your work item using the AB# syntax.

  • Stage, commit, and push your changes by running these commands:
git add .
git commit -m "Added percentage function. Fixed AB#1"
git push

What does AB# do?

The AB# prefix in a GitHub commit message tells Azure DevOps to link that commit to a specific work item. Using the keyword Fixed before AB# also transitions the work item's state to Resolved automatically.

Replace 1 with whatever work item ID you noted earlier. The AB# syntax only works in commit messages, pull request descriptions, or issue descriptions. It does not work in comments or pull request titles.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete app/calculator.py:

def add(a, b):
    return a + b


def subtract(a, b):
    return a - b


def multiply(a, b):
    return a * b


def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b


def percentage(value, percent):
    return value * (percent / 100)

For tests/test_calculator.py, confirm each of these sections is present in order:

  • The updated import line including percentage.
  • The test_add, test_subtract, test_multiply, test_divide, and test_divide_by_zero functions (unchanged from before).
  • The new test_percentage function at the bottom.
Verify the traceability link

Your push triggers the pipeline. Before you check the result, think about what you expect to happen to both the pipeline and the work item.

  • Return to your python-pipeline-demo project in Azure DevOps.
  • Select Pipelines in the left sidebar.
  • Wait for the pipeline run to complete. All three stages (Build, Lint, Test) should pass with green checkmarks.

Before you check the work item, what do you expect its state to be? Think about what the keyword Fixed in your commit message should have triggered.

  • Select Boards in the left sidebar.
  • Select Work Items.
  • Open your User Story titled Add percentage calculation to calculator.
  • Look at the State field. It should now show Resolved.
  • Scroll down to the Development section. You should see your GitHub commit linked there with the commit message you used.

You now have end-to-end traceability. The work item links to the commit, and the commit triggered the pipeline. From plan to code to validated pipeline, every step is connected.

Why does traceability matter?

In production teams, auditors and team leads need to answer questions like "why was this code changed?" and "was it tested?" Traceability links answer both instantly. The work item shows the reason for the change, the linked commit shows what changed, and the pipeline proves it was validated.

Work item state did not change?

The AB# syntax must appear in the commit message body, not in a comment or pull request title. Make sure you used Fixed AB# followed by your actual work item ID number. Also confirm your GitHub repository is properly connected to Azure DevOps (the pipeline ran, which proves the connection exists).

help me troubleshoot why my Azure Boards work item did not transition to Resolved after my commit

Your pipeline now tells the full story: what was planned, what code was written, and that it passed every quality gate. Next up, you will take on a bonus challenge to add deployment approval gates to your pipeline.

Secret mission

Add a Deployment Stage with Approval Gates

What if your pipeline could pause and wait for human approval before deploying? In real production systems, code that passes all quality gates still needs a human sign-off before reaching users. Add a deployment stage with environment approval checks to simulate a real release workflow.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on the Azure DevOps free tier, so there are no ongoing costs.

Resources you used:

  • Azure DevOps organization and project python-pipeline-demo (free tier, no charges).
  • GitHub repository python-pipeline-demo with pipeline configuration.
  • Local project folder python-pipeline-demo on your Desktop.
  • Azure pay-as-you-go subscription (no charges incurred for free-tier usage).

Why no cost warning?

The Azure DevOps free tier includes 1 Microsoft-hosted parallel job with up to 1,800 minutes per month. This project uses approximately 10-15 minutes total, well within the free allowance.

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend this pipeline further.

  • Your Azure DevOps organization and project persist at no cost.
  • The pipeline triggers on each push to main, consuming free-tier minutes.
  • Your GitHub repository remains available for future use.
  • Your local python-pipeline-demo folder stays on your Desktop.

Pause - I'll come back to this later

Stop pushing to the repository. No pipeline runs means no minutes consumed. Everything stays configured and ready to resume.

  • Stop pushing commits to your GitHub repository. The pipeline only triggers on pushes to main.
  • Your Azure DevOps project, pipeline configuration, and Boards work items remain intact.
  • When you are ready to continue, push a new commit and the pipeline resumes automatically.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to clean up completely.

  • Delete the Azure DevOps project: open your python-pipeline-demo project in Azure DevOps, select Project settings in the bottom-left corner, scroll down under Overview and select Delete. Type the project name to confirm, then select Delete again.
  • Delete the GitHub repository: open your python-pipeline-demo repository on GitHub, click Settings, scroll down to the Danger Zone section, and click Delete this repository. Confirm by typing the repository name and clicking Delete this repository.
  • Delete your local project files by running this command in your terminal:
rm -rf ~/Desktop/python-pipeline-demo
  • Optionally cancel your Azure pay-as-you-go subscription if you no longer need it. Navigate to the Azure portal, open Subscriptions, select your subscription, and select Cancel subscription. Only do this if you have no other Azure resources you want to keep.

Nice Work!

Nice Work!

You just built a production-style multi-stage CI/CD pipeline in Azure DevOps from scratch. Every push to your repository now triggers automated build, lint, and test stages with clear visual feedback on exactly where problems occur.

You've learned how to:

  • Author a multi-stage YAML pipeline (Build, Lint, Test) running on Microsoft-hosted agents that gives you instant visual feedback on where a failure occurred.
  • Publish test results directly to Azure DevOps using the PublishTestResults@2 task, so you can drill into pass/fail details without reading raw logs.
  • Link Azure Boards work items to GitHub commits using the AB# syntax, creating end-to-end traceability from planning to code to pipeline.
  • Secret Mission: Added a deployment stage with environment approval gates, simulating a real production release workflow with human-in-the-loop verification.

Ready to quiz yourself?