In the previous post I introduced you to multi-stage YAML pipelines. Build/Release pipelines vs. a multi-stage pipeline, enabling the preview feature (it’s still in preview at the time of writing) and an overview of the structure of the file.
Now we’ll take a more detailed look at an example multi-stage YAML file. This is geared at building apps for Business Central but the principles are transferable to any other application you are targeting. This is the example that I talked through in my recent webinar. If you’d rather view it as a gist you can see that here.
trigger:
- '*'
pool:
name: Default
variables:
image_name: mcr.microsoft.com/businesscentral/sandbox
container_name: Build
company_name: My Company
user_name: admin
password: P@ssword1
license_file: C:\Users\james.pearson.TECMAN\Desktop\Licence.flf
stages:
- stage: build
displayName: Build
jobs:
- job: Build
pool:
name: Default
steps:
- task: PowerShell@1
displayName: Create build container
inputs:
scriptType: inlineScript
inlineScript: >
Import-Module navcontainerhelper;
$Credential = [PSCredential]::new('$(user_name)',(ConvertTo-SecureString '$(password)' -AsPlainText -Force));
New-NavContainer -accept_eula -accept_outdated -containerName '$(container_name)' -auth NavUserPassword -credential $Credential -image $(image_name) -licenseFile $(license_file) -doNotExportObjectsToText -restart no -shortcuts None -useBestContainerOS -includeTestToolkit -includeTestLibrariesOnly -updateHosts
- task: PowerShell@1
displayName: Copy source into container folder
inputs:
scriptType: inlineScript
inlineScript: >
$SourceDir = 'C:\ProgramData\NavContainerHelper\Extensions\$(container_name)\Source';
New-Item $SourceDir -ItemType Directory;
Copy-Item '$(Build.SourcesDirectory)\*' $SourceDir -Recurse -Force;
- task: PowerShell@1
displayName: Compile app
inputs:
scriptType: inlineScript
inlineScript: >
Import-Module navcontainerhelper;
$SourceDir = 'C:\ProgramData\NavContainerHelper\Extensions\$(container_name)\Source';
$Credential = [PSCredential]::new('$(user_name)',(ConvertTo-SecureString '$(password)' -AsPlainText -Force));
Compile-AppInNavContainer -containerName '$(container_name)' -appProjectFolder $SourceDir -credential $Credential -AzureDevOps -FailOn 'error';
- task: PowerShell@1
displayName: Copy app into build artifacts staging folder
inputs:
scriptType: inlineScript
inlineScript: >
$SourceDir = 'C:\ProgramData\NavContainerHelper\Extensions\$(container_name)\Source';
Copy-Item "$SourceDir\output\*.app" '$(Build.ArtifactStagingDirectory)'
- task: PowerShell@1
displayName: Publish and install app into container
inputs:
scriptType: inlineScript
inlineScript: >
Import-Module navcontainerhelper;
Get-ChildItem '$(Build.ArtifactStagingDirectory)' | % {Publish-NavContainerApp '$(container_name)' -appFile $_.FullName -skipVerification -sync -install}
- task: PowerShell@1
displayName: Run tests
inputs:
scriptType: inlineScript
inlineScript: >
$Credential = [PSCredential]::new('$(user_name)',(ConvertTo-SecureString '$(password)' -AsPlainText -Force));
$BuildHelperPath = 'C:\ProgramData\NavContainerHelper\Extensions\$(container_name)\My\BuildHelper.app';
Download-File 'https://github.com/CleverDynamics/al-build-helper/raw/master/Clever%20Dynamics_Build%20Helper_BC14.app' $BuildHelperPath;
Publish-NavContainerApp $(container_name) -appFile $BuildHelperPath -sync -install;
$Url = "http://{0}:7047/NAV/WS/{1}/Codeunit/AutomatedTestMgt" -f (Get-NavContainerIpAddress -containerName '$(container_name)'), '$(company_name)';
$AutomatedTestMgt = New-WebServiceProxy -Uri $Url -Credential $Credential;
$AutomatedTestMgt.GetTests('DEFAULT',50100,50199);
Import-Module navcontainerhelper;
$ResultPath = 'C:\ProgramData\NavContainerHelper\Extensions\$(container_name)\my\Results.xml';
Run-TestsInBcContainer -containerName '$(container_name)' -companyName '$(company_name)' -credential $Credential -detailed -AzureDevOps warning -XUnitResultFileName $ResultPath -debugMode
- task: PublishTestResults@2
displayName: Upload test results
inputs:
failTaskOnFailedTests: true
testResultsFormat: XUnit
testResultsFiles: '*.xml'
searchFolder: C:\ProgramData\NavContainerHelper\Extensions\$(container_name)\my
- task: PublishBuildArtifacts@1
displayName: Publish build artifacts
inputs:
ArtifactName: App Package
PathtoPublish: $(Build.ArtifactStagingDirectory)
- task: PowerShell@1
displayName: Remove build container
inputs:
scriptType: inlineScript
inlineScript: >
Import-Module navcontainerhelper;
Remove-NavContainer $(container_name)
condition: always()
- stage: Release
displayName: Release
condition: and(succeeded(), eq(variables['build.sourceBranch'], 'refs/heads/master'))
jobs:
- deployment:
displayName: Release
pool:
name: Default
environment: Release
strategy:
runOnce:
deploy:
steps:
- task: PowerShell@1
displayName: Copy artifacts to release directory
inputs:
scriptType: inlineScript
inlineScript: >
$Path = Split-Path '$(System.ArtifactsDirectory)' -Parent;
$Artifact = "$Path\App Package\*.app";
Copy-Item $Artifact 'C:\Release\';
Build
I’ve got a simple Business Central app. I want to use the Build stage to take my source AL code and:
- Compile it into an .app file
- Publish and install it into a container
- Load the test codeunits and methods into the DEFAULT test suite
- Run the tests
- Upload the test results to the build
- Upload the .app file as a build artifact
Almost all of these steps are performed with a PowerShell task. I won’t talk you through the PowerShell, you can read the code in the steps and read more about the use of the navcontainerhelper module on Freddy’s blog or dig around the PowerShell posts on my blog. I will just mention that loading the test codeunits relies on our AL Build Helper app as described previously.
The final step to run is a PowerShell script to remove the container that has been created for the build. I always want this step to run, even if another step above it has failed. See the condition: always() line that takes care of that.
Release

So far this is fairly familiar territory and stuff that we’ve been through before. The more interesting part for the purposes of this post is having a second stage to run.
First notice the condition attached to the Release stage:
and(succeeded(), eq(variables['build.sourceBranch'], 'refs/heads/master'))
build.SourceBranch is one of the built-in variables that you can access in the pipeline which holds the name of the branch that triggered the build. This condition means the Release stage will only run if the pipeline has succeeded up to this point and the pipeline was triggered by the master branch.
This is useful for a CI/CD scenario where you want to trigger the pipeline for changes to any branch (and why would you not?) but only want to Release the code when it is merged back into the master branch.
My pipeline uses a specific type of deployment job which allows you to target a particular environment. The ‘Environments’ menu item is displayed when you enable the multi-stage pipelines preview feature. Including a deployment job in your pipeline will instruct Azure DevOps to automatically download the artifacts from the Build stage to the agent (see here).
The Release stage can include as many steps as you need depending on your definition of ‘releasing’ your software. It might include steps to:
- Use PowerShell to publish the .app file to an on-prem instance of Business Central
- Use the admin API to publish the .app file to a Business Central SaaS tenant
- Upload the .app file to some other location: FTP, SharePoint, network path
As a very simple example my Release stage is simply going to copy the .app file that was downloaded as an artifact from the Build stage into a C:\Release folder on the build agent.
Environments
One of the good things about creating an environment and targeting it with a deployment job is that you can see at a glance which version of your software the environment is hosting.

Drill down on the environment to see details of the current and previous versions that have been deployed and all the relevant corresponding detail – the pipeline that deployed the software, the logs, commits and work items. Beautiful.
