CoursesAzure DevOps Engineer ProfessionalRelease pipelines & approvals

Release pipelines & approvals

Promote one artifact through gated envs.

Intermediate30 min · lesson 10 of 15

A canal lock is a staircase for boats. One boat enters at the low end and rises one chamber at a time: dev, test, staging, production. Each gate opens only when the conditions are right, when the water levels match and the lock keeper signs off. Nobody rebuilds the boat between chambers. The hull that clears the last gate is bolt for bolt the hull the inspector checked at the first. A release pipeline works the same way. In Azure Pipelines the chambers are *environments*, the gates are *checks and approvals*, and the boat is a pipeline artifact (the packaged output of your build).

Build one thing, promote it everywhere

Three words carry most of the weight here. An artifact is the frozen output of your build stage, a zip file, a container image, a compiled template, published exactly once with a publish: step. An environment is a named deployment target you create under Pipelines → Environments. It keeps a deployment history, has its own permissions, and, the part that matters most here, holds the checks. A deployment job is a special kind of job that points at an environment. It records every run in that environment's history and downloads the current run's artifacts for you, so the deploy steps receive exactly what Build published and nothing else.

Azure DevOps still ships the old Releases screen with its drag-and-drop stages. Treat it as legacy. New gating features land in multi-stage YAML only (YAML is the plain-text format Azure Pipelines uses to describe a pipeline), and the az pipelines release command group in the CLI (command-line interface) drives those classic definitions only. Everything below uses the current model, with every stage living in one version-controlled azure-pipelines.yml.

azure-pipelines.yml
trigger:
branches: { include: [main] }
stages:
- stage: Build
jobs:
- job: build
pool: { vmImage: ubuntu-latest }
steps:
- script: dotnet publish src/Orders.Api -c Release -o $(Build.ArtifactStagingDirectory)
displayName: Build once
- publish: $(Build.ArtifactStagingDirectory)
artifact: webapp # the ONE artifact promoted everywhere
- stage: Test
dependsOn: Build
jobs:
- deployment: deploy_test
environment: orders-test # no checks -> deploys automatically
pool: { vmImage: ubuntu-latest }
strategy:
runOnce:
deploy:
steps: # deployment jobs auto-download artifacts
- task: AzureWebApp@1
inputs:
azureSubscription: sc-orders-nonprod
appName: app-orders-test
package: $(Pipeline.Workspace)/webapp/*.zip
- stage: Prod
dependsOn: Test
jobs:
- deployment: deploy_prod
environment: orders-prod # approvals + checks attach HERE, not in YAML
pool: { vmImage: ubuntu-latest }
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1
inputs:
azureSubscription: sc-orders-prod
appName: app-orders-prod
deployToSlotOrASE: true
resourceGroupName: rg-orders-prod
slotName: staging # land in the slot; swap comes later
# Run 20260714.2 — stage view:
# Build [ok] succeeded 1m 42s
# Test [ok] succeeded 58s
# Prod [||] waiting "1 approval needs review"
Rebuilding in every environment breaks the promise that test made
If each stage rebuilds from source instead of promoting the same artifact, dependency versions drift and what you tested is not what ships. The rebuild also reopens the supply-chain window. A package version that got compromised *after* your test build gets pulled down fresh into the production build, with zero test coverage on it. Build once, publish: once, and let deployment jobs download that exact artifact. Then give each environment its own service connection (sc-orders-nonprod versus sc-orders-prod) so a job in the test stage does not hold credentials that can reach production at all.

The gate is bolted to the environment, not to the pipeline file

Here is the design decision that makes the gating trustworthy. Approvals and checks are configured on the environment resource in the web interface (Environments → orders-prod → Approvals and checks), never in the pipeline file. When a stage asks for a protected resource, an environment, a service connection, an agent pool, a variable group, a secure file, a repository, Azure Pipelines runs every check attached to that resource before the stage is allowed to start. Because the checks sit outside the YAML, a pull request that rewrites the pipeline cannot quietly delete them. Whoever administers the environment owns the gate. Whoever writes the pipeline does not.

The catalog is short enough to hold in your head. Approvals: named people or groups, a minimum number of approvers, an option that stops people approving their own runs, and a timeout (if nobody acts before it expires, the stage is marked *skipped*, not failed). Branch control: only runs from main, optionally requiring branch protection. Business hours. Exclusive lock: deployments queue up one at a time, so two runs can never converge on production together. Invoke Azure Function and Invoke REST API, for gates you write yourself as code or as a web service the pipeline calls. Query Azure Monitor alerts: hold the release while incident alerts are firing. Required template: the stage has to extend a template you approved. Evaluate artifact: custom policy over container-image artifacts. Every check on every resource the stage consumes has to pass before it starts. They are combined with AND, so one green check never covers for another, and a single outright rejection stops the stage dead.

terminal: trigger and watch a gated run
# One-time: CLI extension + defaults
az extension add --name azure-devops
az devops configure --defaults \
organization=https://dev.azure.com/contoso project=Orders
# Kick off the CD pipeline from main
az pipelines run --name orders-cd --branch main -o table
# Run ID Number Status Result Pipeline ID Pipeline Name Source Branch Queued Time Reason
# ------ ---------- ---------- ------ ----------- ------------- ------------- -------------------------- ------
# 4127 20260714.2 notStarted 42 orders-cd main 2026-07-14 09:12:33.123456 manual
# Poll it: a run blocked at a gate still reports "inProgress"
az pipelines runs show --id 4127 --query "{status:status, result:result}"
# {
# "status": "inProgress", <- Prod stage is parked on the orders-prod approval
# "result": null
# }

Approvals for YAML environments get granted from the pipeline run view. The waiting stage shows a Review prompt where an approver can approve, reject, or defer the run to a later effective time. You can also drive it through the _apis/pipelines/approvals REST endpoint, which is what you want when ServiceNow or another change tool is the approver of record.

What the deploy stage should actually do

A deployment job usually does two things, in order. First it brings the infrastructure to the shape you declared. Then it lands the application on top. Running your infrastructure as code (IaC, the practice of describing servers, databases and networks in files instead of clicking through a portal) inside the release stage keeps environments honest, because every deploy restates the desired state. Drift, the slow gap that opens between what your files say and what is really running, dies at the next release instead of piling up. With Bicep that is one idempotent az deployment group create, and idempotent means running it a second time changes nothing. Terraform needs more care. Run terraform plan -out=tfplan *before* the gate, publish tfplan as an artifact, and apply that exact file after approval. The approver then signed off on a specific list of changes, not on whatever a fresh plan happens to compute an hour later. Writing the templates themselves belongs to the ARM and Bicep lesson and the Terraform lesson. Here you care about where they run.

deploy stage: infra converges before the app lands
# Bicep flavor: idempotent, safe to run on every release
- task: AzureCLI@2
inputs:
azureSubscription: sc-orders-prod
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az deployment group create \
--resource-group rg-orders-prod \
--template-file infra/main.bicep \
--parameters environmentName=prod \
--query "properties.{state:provisioningState, took:duration}"
# {
# "state": "Succeeded",
# "took": "PT47.3S"
# }
# Terraform flavor: apply the SAME plan file the approver reviewed at the gate
- script: |
terraform init -input=false
terraform apply -input=false tfplan # tfplan was planned + published pre-approval
workingDirectory: infra/
# Apply complete! Resources: 2 added, 1 changed, 0 destroyed.

Slot swap: ship it warm, keep settings where they belong

On Azure App Service, the last hop should not be a deploy at all. It should be a slot swap. A deployment slot is a second, parallel copy of your app (app-orders-prod-staging) with its own hostname and its own configuration, like a spare stage set built behind the curtain while the current scene is still playing. You deploy the artifact to the staging slot, smoke-test it (a quick check that the obvious things work) against real production infrastructure, then swap. The platform copies the production slot's settings onto the staging instances, restarts them, and warms them up. By default warm-up is one plain web request to the application root, and any response at all counts as warm, which is weaker than it sounds. Set WEBSITE_SWAP_WARMUP_PING_PATH and WEBSITE_SWAP_WARMUP_PING_STATUSES to hit a real health endpoint and accept only the status codes you name. Only when warm-up succeeds do the front ends flip routing. No cold start in production, no dropped requests, and your previous build is now sitting in the staging slot, one command away from an instant swap back.

Configuration is where this bites. By default, app settings and connection strings travel with the code during a swap. Any setting you mark as a deployment slot setting (sticky) stays with the slot name instead. That is what you want for ENVIRONMENT_NAME, a staging-only connection string, or an Application Insights role name. A few things never swap no matter what: managed identities, custom domains and TLS bindings (TLS is the encryption behind https), and scale settings belong permanently to each slot. Forgetting to mark a setting sticky is the classic slot bug, and it shows up as production suddenly running against staging's database.

terminal: slots, sticky settings, swap
# Create a staging slot cloned from production config (one-time)
az webapp deployment slot create -g rg-orders-prod -n app-orders-prod \
--slot staging --configuration-source app-orders-prod
# Mark per-environment settings STICKY so they stay with the slot on swap
az webapp config appsettings set -g rg-orders-prod -n app-orders-prod \
--slot staging \
--slot-settings ENVIRONMENT_NAME=staging APPINSIGHTS_ROLE=orders-staging
# [
# { "name": "ENVIRONMENT_NAME", "slotSetting": true, "value": "staging" },
# { "name": "APPINSIGHTS_ROLE", "slotSetting": true, "value": "orders-staging" }
# ]
# Rehearse: phase 1 applies prod's settings to staging but keeps traffic put
az webapp deployment slot swap -g rg-orders-prod -n app-orders-prod \
--slot staging --action preview
# Validate https://app-orders-prod-staging.azurewebsites.net, then complete:
az webapp deployment slot swap -g rg-orders-prod -n app-orders-prod \
--slot staging --target-slot production --action swap
# (no output on success — front ends flip routing after warm-up;
# --action reset cancels the pending swap instead)

How many gates, and where to put them

Gates cost something. Every manual approval is a queue, and an approver who rubber-stamps twenty releases a day has stopped reading them. Put gates only where the risk changes. In practice that usually means nothing before staging, then a human approval plus an Azure Monitor alerts check on production. Configure at least two eligible approvers, because one person is a single point of failure the week they go on holiday. Turn on the option that stops approvers approving their own runs, so the person who wrote the change is not the person who waves it through. Then let the environment's deployment history answer your auditors: every production entry records who approved which run of which artifact from which commit.

Know what this model does not do, too. Checks gate the *start* of a stage, not its blast radius. Once you approve, the deploy runs all the way to the end. A swap back restores the binary in seconds but does nothing for your data, so real reversibility depends on backward-compatible migrations, which the zero-downtime lesson takes on. And an approval is a yes-or-no switch: production gets 0% of the new build or 100% of it, with nothing in between. Making that dial continuous is the next step. Canary rings and traffic splitting let a release earn production traffic a few percent at a time. That is progressive delivery, and it is up next.

One more decision you may inherit. Classic release pipelines and YAML multi-stage pipelines can both promote artifacts, so a team on the old model is not broken, only stuck. YAML is where the new features land, and it lives in git next to the code, which is the whole reason the gating story holds together. Environments are the piece that carries the deployment record and the checks: approvals, branch control, business hours, Azure Monitor gates. Aim for promotions that are boring. Same bits, new gate.

Traceability is the story you tell an auditor, and it is a chain: commit fingerprint (the SHA that git stamps on every commit) → build ID → artifact → the environment's deployment history. When production breaks at 2am, that chain tells you exactly what changed and who let it through. Without it you are reconstructing the truth from Slack messages and memory.

Try this

Create an Azure Pipelines environment called staging and attach a check that requires your approval. Then write a throwaway stage that deploys a file artifact into it, and practice both approving and rejecting the run, so you have watched the pipeline park and watched it stop.

terminal
az pipelines environment list -o table
# In YAML:
# - stage: DeployStaging
# jobs:
# - deployment: Deploy
# environment: staging
# strategy:
# runOnce:
# deploy:
# steps:
# - script: echo deploying $(Build.BuildId)
az pipelines runs list --pipeline-name release-lab --top 3 -o table
output
$ az pipelines environment list -o table
Name Namespace
-------- ---------
dev
staging
prod
# Sample output — run waits: "Waiting for approval on environment staging"

Takeaway

Build once, then promote that same artifact through named environments. The approvals and checks are the lock gates between the chambers, and they belong to the environment, not to the pipeline file.

Next: hunt down any rebuild-per-environment pattern in your own pipelines and kill it, then put the artifact's Build.BuildId in every release note so the trail back to the commit is never in doubt.

Quick check
01A production approval gate is set up under Environments → orders-prod → Approvals and checks. A developer opens a pull request that edits azure-pipelines.yml to strip the approval off the Prod stage. What happens to the gate?
Correct — Approvals and checks are attached to the environment resource in the web interface, not to the pipeline file. Whoever administers the environment owns the gate, and a pull request against the pipeline cannot delete it.
Incorrect — It disappears the moment the pull request merges to main, because the pipeline file defines everything a stage does. misses it. The answer is A. Approvals and checks are attached to the environment resource in the web interface, not to the pipeline file. Whoever administers the environment owns the gate, and a pull request against the pipeline cannot delete it.
Incorrect — It disappears as soon as the pull request is opened, before anything is merged or run. misses it. The answer is A. Approvals and checks are attached to the environment resource in the web interface, not to the pipeline file. Whoever administers the environment owns the gate, and a pull request against the pipeline cannot delete it.
Incorrect — It survives but drops to a non-blocking warning that the deploy can wave itself past. misses it. The answer is A. Approvals and checks are attached to the environment resource in the web interface, not to the pipeline file. Whoever administers the environment owns the gate, and a pull request against the pipeline cannot delete it.
02Of the checks you can attach to an Azure DevOps environment, which one stops two pipeline runs deploying to the same production environment at the same moment?
Incorrect — Business hours, which only allows deployments inside a time window you configure. misses it. The answer is C. Exclusive lock is the environment check that serializes deployments, so two runs can never land on the same environment together.
Incorrect — Branch control, which only allows runs that come from an approved branch such as main. misses it. The answer is C. Exclusive lock is the environment check that serializes deployments, so two runs can never land on the same environment together.
Correct — Exclusive lock is the environment check that serializes deployments, so two runs can never land on the same environment together.
Incorrect — Required template, which forces the stage to extend a pipeline template you approved. misses it. The answer is C. Exclusive lock is the environment check that serializes deployments, so two runs can never land on the same environment together.
03You are setting up the production approval for a release. You want to stop a developer approving their own deployment, and you also want one approver going on holiday to never block every release. Which setup is best?
Incorrect — A single named approver who is always reachable, giving production releases one clear owner. misses it. The answer is B. Two or more eligible approvers means no single holiday stalls releases, and restricting approvers from approving their own runs keeps the separation of duties intact.
Correct — Two or more eligible approvers means no single holiday stalls releases, and restricting approvers from approving their own runs keeps the separation of duties intact.
Incorrect — An exclusive lock check, so only one run can be approved at a time. misses it. The answer is B. Two or more eligible approvers means no single holiday stalls releases, and restricting approvers from approving their own runs keeps the separation of duties intact.
Incorrect — A business-hours check, so approvals can only be granted during working hours. misses it. The answer is B. Two or more eligible approvers means no single holiday stalls releases, and restricting approvers from approving their own runs keeps the separation of duties intact.

Related