---
title: Create a YAML Build and Release Pipeline With Stages
description: "To me this is one of the biggest improvements we get from using Git in our repos. While we can still use different classic build and [classic release pipelines](http://ariste.info/dynamics365almguide/create-a-classic-release-pipeline/), with the YAML pipelines we can have pipelines with different **stages**…"
url: "https://ariste.info/dynamics365almguide/create-a-yaml-build-and-release-pipeline-with-stages/"
updated: "2025-10-28"
category: Unified experience ALM / Pipelines
---

# Create a YAML Build and Release Pipeline With Stages

To me this is one of the biggest improvements we get from using Git in our repos.

While we can still use different classic build and [classic release pipelines](http://ariste.info/dynamics365almguide/create-a-classic-release-pipeline/), with the YAML pipelines we can have pipelines with different **stages** that will first build and then release.

## What are Stages?

Imagine you’re running a mini factory for your code. Each **stage** is like a **station** on the assembly line where something specific happens before your code is ready to be deployed.

For example:

- You might start with a **Build stage**, where your code is compiled and a package is created.
- Then comes a **Deploy UAT stage**, where you deploy the code to the UAT environment.
- After that, you could have a **Deploy Production stage** that pushes your changes to production. Yes, production!

Each Azure DevOps stage can have multiple **jobs and tasks**, and you can decide when and how each stage runs, like "only deploy to production if someone approves it".

## Template pipeline

To speed things up a little bit, I've created a template pipeline with a build stage, a UAT release stage, and a Prod release stage.

You can download it here: [https://raw.githubusercontent.com/aariste/Unified-Experience-ALM/refs/heads/main/build-release.yml](https://raw.githubusercontent.com/aariste/Unified-Experience-ALM/refs/heads/main/build-release.yml)

Now let's go through it and learn something. I will use line numbers as a reference, but I will also post pieces of the file.

From lines 1 to 26 we have this:

```
# Sample YML pipeline for X++ builds (multi-stage)
# https://docs.microsoft.com/en-us/dynamics365/fin-ops-core/dev-itpro/dev-tools/hosted-build-automation

name: $(Date:yy.MM.dd)$(Rev:.r)

trigger:
  - none

schedules:
  - cron: "0 6 * * *"           # 8:00 every day
    displayName: Daily Build at 08:00
    branches:
      include:
        - main
    always: false

# Global variables stay as-is
variables:
  App1Package: 'Microsoft.Dynamics.AX.Application1.DevALM.BuildXpp'
  App2Package: 'Microsoft.Dynamics.AX.Application2.DevALM.BuildXpp'
  AppSuitePackage: 'Microsoft.Dynamics.AX.ApplicationSuite.DevALM.BuildXpp'
  PlatPackage: 'Microsoft.Dynamics.AX.Platform.DevALM.BuildXpp'
  ToolsPackage: 'Microsoft.Dynamics.AX.Platform.CompilerPackage'
  MetadataPath: '$(Build.SourcesDirectory)\Metadata'
  NugetConfigsPath: '$(Build.SourcesDirectory)\Tools\Build'
  NugetsPath: '$(Pipeline.Workspace)\NuGets'
```

You can see it has no CI trigger, but it has a schedule. In this case it will run at 8am (CET) every day, only for the main branch (**include**), and only if there have been any changes to the codebase (**always: false**).

### Build stage

Next we have the **first stage**, which builds the code:

```
stages:
# ---------- Stage 1: Build ----------
- stage: Build
  displayName: X++ Build & Package
  jobs:
  - job: BuildXpp
    pool:
      vmImage: 'windows-latest'
      demands:
        - msbuild
        - visualstudio
    displayName: Build solution and create deployable package

    steps:
      # Install NuGet and use -ExcludeVersion option
      - task: NuGetCommand@2
        displayName: 'NuGet custom install Packages'
        inputs:
          command: custom
          arguments: 'install -Noninteractive $(NugetConfigsPath)\packages.config -ConfigFile $(NugetConfigsPath)\nuget.config -Verbosity Detailed -ExcludeVersion -OutputDirectory "$(NugetsPath)"'
```

This is marked by the **stages** keyword to signal that the pipeline has stages, and the **-stage** keyword for each stage.

Then, for every stage, we still have **jobs**. This is exactly the same as pipelines with no stages.

For each job, we have a **pool** with the VM image definition, and also **steps** and **-tasks**. Each of these tasks is a single operation inside the stage.

### Deploy UAT stage

After building, we've got the package ready to be applied.

```
# ---------- Stage 2: Deploy UAT ----------
- stage: DeployUAT
  displayName: Deploy to UAT
  dependsOn: Build
  condition: succeeded()
  jobs:
  - deployment: DeployToUAT
    timeoutInMinutes: 360
    pool:
      vmImage: 'windows-latest'
    displayName: Deploy package to UAT
    environment: 'D365-UAT'   # create this Environment in Azure DevOps and add approvals/checks as needed
    strategy:
      runOnce:
        deploy:
          steps:
            - task: PowerPlatformToolInstaller@2
              displayName: 'Power Platform Tool Installer '

            - task: PowerPlatformWhoAmi@2
              displayName: 'Power Platform WhoAmI '
              inputs:
                authenticationType: PowerPlatformSPN
                PowerPlatformSPN: 'CSNA-UAT'

            - task: PowerPlatformDeployPackage@2
              displayName: 'Power Platform Deploy Package '
              inputs:
                authenticationType: PowerPlatformSPN
                PowerPlatformSPN: 'CSNA-UAT'
                PackageFile: '$(Pipeline.Workspace)/drop/CloudDeployablePackage/TemplatePackage.dll'
```

Again, the stage has a **jobs** part and **steps** that run. These steps are the same as the ones used in the [classic release pipeline](http://ariste.info/dynamics365almguide/create-a-classic-release-pipeline/).

What I want you to remember from this is the **environment** in the **deployment**.

```
jobs:
  - deployment: DeployToUAT
    timeoutInMinutes: 360
    pool:
      vmImage: 'windows-latest'
    displayName: Deploy package to UAT
    environment: 'D365-UAT'   # create this Environment in Azure DevOps and add approvals/checks as needed
```

For the UAT stage, it's called D365-UAT. We'll talk about the environment in a minute.

Before that I want to remark on the dependsOn and condition on the stage:

```
- stage: DeployUAT
  displayName: Deploy to UAT
  dependsOn: Build
  condition: succeeded()
```

What does that mean? First, the **dependsOn** item tells us that running the DeployUAT stage depends on the Build stage. It was clear without an explanation.

Then the **condition** expected is *succeeded()*. This means that if the Build stage fails, the DeployUAT won't run.

### Deploy Prod stage

And, finally, the deploy to prod stage, which essentially is the same as the UAT but for another environment.

Regarding the PowerPlatformSPN value, this is the service connection. In the YAML pipeline case, you need to create the service connection beforehand. There's no new button here.

## About Deploying to Production

Yes, **there are no guardrails here**. We can deploy code directly to prod; no need to go through a sandbox environment first.

Does that mean that we should do it? **Hell no!** Even if Microsoft allows us to deploy code to production, **we're the owners and final responsible parties for our custom code**.

We still need to adhere to best practices, code with quality in mind, and do proper testing, both technical and functional, before deploying anything to prod.

## Environments

So what are environments? In Azure DevOps, environments are the places your code gets deployed to, like UAT or Prod.

When you add an environment to a YAML file, it gets automatically created on DevOps:

![Environments](http://ariste.info/wp-content/uploads/2025/10/image-29.png)
Environments

Each environment can have its own approvals, checks, and permissions, so you can control who and what gets deployed where.

![Environment approvals](http://ariste.info/wp-content/uploads/2025/10/image-30.png)
Environment approvals

And it's here where you MUST add [approvals for your environments](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/approvals?view=azure-devops&tabs=check-pass), especially the production one!

You don't want to start servicing the production environment in the middle of the workday, right? There's the [business hours approval](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/approvals?view=azure-devops&tabs=check-pass#business-hours) for that!

![With great power, comes great responsibility](http://ariste.info/wp-content/uploads/2025/10/f1a62a67-fa0d-4742-9c29-0fdc863184a2_480x257-1.gif)
With great power, comes great responsibility

**Please, use this new superpower with care.**

And to end this, here's how the pipeline run with stages looks like:

![Pipeline run with stages](http://ariste.info/wp-content/uploads/2025/10/image-31.png)
Pipeline run with stages

## Timeouts

And of course, we have a limitation! One that we can overcome easily and that you might've noticed already.

In the deploy stages, I have set a manual timeout:

```
timeoutInMinutes: 360
```

The Azure-hosted pipelines' jobs can run for up to 60 minutes, and deploying using the Power Platform tools is taking a bit over an hour.

To avoid hitting the timeout, we need to purchase an additional agent job and set the timeoutInMinutes value in the stage.

#### Subscribe!

Receive an email when a new post is published
