Release pipelines & approvals
Promote one artifact through gated envs.
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.
trigger:branches: { include: [main] }stages:- stage: Buildjobs:- job: buildpool: { 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: TestdependsOn: Buildjobs:- deployment: deploy_testenvironment: orders-test # no checks -> deploys automaticallypool: { vmImage: ubuntu-latest }strategy:runOnce:deploy:steps: # deployment jobs auto-download artifacts- task: AzureWebApp@1inputs:azureSubscription: sc-orders-nonprodappName: app-orders-testpackage: $(Pipeline.Workspace)/webapp/*.zip- stage: ProddependsOn: Testjobs:- deployment: deploy_prodenvironment: orders-prod # approvals + checks attach HERE, not in YAMLpool: { vmImage: ubuntu-latest }strategy:runOnce:deploy:steps:- task: AzureWebApp@1inputs:azureSubscription: sc-orders-prodappName: app-orders-proddeployToSlotOrASE: trueresourceGroupName: rg-orders-prodslotName: 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"
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.
# One-time: CLI extension + defaultsaz extension add --name azure-devopsaz devops configure --defaults \organization=https://dev.azure.com/contoso project=Orders# Kick off the CD pipeline from mainaz 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.
# Bicep flavor: idempotent, safe to run on every release- task: AzureCLI@2inputs:azureSubscription: sc-orders-prodscriptType: bashscriptLocation: inlineScriptinlineScript: |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=falseterraform apply -input=false tfplan # tfplan was planned + published pre-approvalworkingDirectory: 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.
# 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 swapaz 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 putaz 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.
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
$ az pipelines environment list -o tableName Namespace-------- ---------devstagingprod# 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.
azure-pipelines.yml to strip the approval off the Prod stage. What happens to the gate?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.