MS Stack Ch 18 — CI/CD with Azure DevOps
YAML pipelines, templates + parameters + extends, agent pools, federated identity for Azure deploys, SDL gates (CodeQL, credential scanner, container scanning), multi-stage deploys, environments + approvals.
Published Updated
Lesson preparation & details
Level: intermediate
By the end, you should be able to
- Design artifact promotion with independently enforced pipeline resource checks
- Distinguish WIF connection trust, pipeline permission and resource authorization
- Correct task inputs, scanner lifecycle and failure evidence without leaking secrets
Bring with you
Sources checked: . A source check does not change the publication or update date.
Editorial review: · What review means
In this article · 32 sections
Review and execution boundary
Azure DevOps Services YAML/task examples, rechecked against official snapshots on 9 October 2026; task input names, installed task revisions, licenses and service-connection settings still require validation in the target organization. YAML blocks are inspected and parsed locally as fragments; they are not Azure Pipelines service-validation or deployment results.
Full-original-breadth conceptual review completed on 7 October 2026, with corrected mechanisms, examples and worked assessments. Exact safe local fixtures and scoped MDX checks are recorded separately in the review evidence. No Azure/IAM writes, Kubernetes cluster, .NET/Bicep compilation or cloud service reproduction was performed; installed-tool availability and each executed model are named in that evidence. Optional deployment commands are learner exercises, not completed actions.
Chapter 18 of From Novice to Fluent on the Modern Microsoft Web Stack — a 22-chapter self-study plan.
Why this chapter
CI/CD is the conveyor belt between a commit and a customer. One implementation of that conveyor belt is Azure DevOps Pipelines — defined in YAML, versioned in the repo, executed by hosted or self-hosted agents, deploying through environments that carry approvals and gates. The public-facing twin is GitHub Actions: a related stages/jobs/steps mental model, but different permissions, event and approval semantics. Familiarity transfers; migration effort and learning time are not established here.
The shipping-grade version of this skill is "I can write a azure-pipelines.yml that builds, tests, packages, and deploys an ASP.NET Core API to App Service with a manual approval before prod". The expert-tier version is "I can factor a pipeline into reusable templates, enforce SDL gates through an extends pipeline plus independently owned resource checks/permissions, deploy with federated workload identity (no stored client secret for that connection, not an absence of all secrets), and orchestrate a region-by-region rollout with bake times and automatic rollback".
Most of what differentiates the two tiers is not new YAML keywords. It is knowing what to factor where, what to never put in a pipeline, and what compliance gates the org expects to find baked in. That is the chapter's payload.
You finish this chapter when you can sit down with a blank repo, write a YAML pipeline that builds and deploys to three environments with the right approvals, and explain — without notes — how extends templates, federated identity, and SDL gates fit together.
Pipeline anatomy — Triggers, stages, jobs, steps, tasks, scripts — the YAML object model.
Reusable YAML — Templates, parameters, extends — DRY pipelines that scale to dozens of repos.
Agents + auth — Microsoft-hosted vs self-hosted; NuGetAuthenticate; service connections.
Artifacts — Pipeline vs build artifacts; publish and consume across jobs and stages.
Environments — Approvals, gates, deployment history, the audit trail prod needs.
SDL + secrets — supported CodeQL/secret/dependency/container scanning, binary hardening and licensed policy tools where applicable; legacy CredScan is deprecated; Key Vault-linked variable groups have explicit access rules.
Concepts and depth
Pipeline anatomy: triggers, stages, jobs, steps
Every Azure DevOps pipeline is a tree. At the root sits a trigger that decides when the pipeline runs. Below that, stages group work into deployable units (CI, Deploy_Dev, Deploy_Prod). Each stage contains one or more jobs, and a normal agent job runs its ordered steps on one agent. Agentless/server jobs are an exception; deployment lifecycle hooks can also have their own execution context. Steps are either a task (versioned, parameterised executable code the Azure DevOps team or third parties publish — UseDotNet@2, AzureWebApp@1, PublishTestResults@2) or a script (a shell line you wrote yourself).
Triggers come in four flavours you actually use. CI triggers fire on a push to a branch; configure them with trigger: at the top of the YAML. PR triggers use pr: for supported GitHub/Bitbucket repositories. Azure Repos Git instead uses a branch-policy build-validation rule; a pr: block alone does not establish PR validation there. Scheduled triggers run on cron strings (nightly main builds, weekly dependency-scan refreshes). Pipeline resource triggers fire when a different pipeline completes successfully — the lever that lets a downstream "release" pipeline react to an upstream "build" pipeline without polling. The fifth, manual, has no special syntax; any pipeline can be queued by a human from the UI.
Variables come in three layers. Inline variables: blocks live in the YAML and are good for non-sensitive build constants (buildConfiguration: 'Release'). Variable groups live in the Library and are referenced by name — they centralise values shared across pipelines and can be linked to Azure Key Vault so the real values never live in version control. Pipeline-run variables are typed by the operator when queuing a manual run; mark them settable at queue time to allow overrides.
A common mistake is treating task vs script as a style preference. It is not. Tasks are reusable code running with the agent/job permissions, not a security sandbox. Both a marketplace task and a script can read accessible secrets or alter artifacts. Prefer supported tasks for their maintained input/authentication contracts, audit their provenance, and use reviewed scripts for custom logic. Log masking is best-effort, not a confidentiality boundary.
Good enough to ship
• Single-file YAML with CI and repository-appropriate PR validation (Azure Repos branch policy), one CI stage and one deploy stage
• Variables defined inline for non-secret build config
• Supported tasks for maintained integrations; reviewed scripts for explicit custom build/test logic
• condition: always() on test-publish so failures still surface results
Expert tier
• Path-filtered triggers per service in a monorepo
• pipeline: resource triggers chaining build → release pipelines
• Variable groups linked to Key Vault, with audit-logged read access
• Task and script provenance/permissions reviewed; neither execution form is a sandbox
The cards combine two relationships that are easy to confuse when reading YAML. Stage, job, and step are nested containers; the trigger starts a run, and an artifact is an output a step publishes, not another nesting level. Trace one build through the picture: the CI stage runs a build job, that job's steps produce and publish a package, and a later deployment job downloads the package. This is the structure the template section will teach you to reuse.
Root Trigger CI / PR / cron / pipeline
L1 Stage CI · Deploy_Dev · Deploy_Prod
L2 Job agent job: one agent; server jobs differ
L3 Step task or script
Out Artifact published, consumed
A trigger starts the run; stages contain jobs, jobs contain steps, and publishing steps make artifacts available to later jobs.
Reusable YAML: templates, parameters, extends
A pipeline that lives only in one repo can grow without discipline. A pipeline that lives in twenty repos cannot — the moment one team forgets to add a CodeQL step, the security org has a CVE story to write. The answer is templates.
Templates come in two shapes. Include templates (- template: build.yml) inline another file's jobs or steps into the consuming pipeline. They are the lightweight "extract a function" move. Extends templates (extends: template: secure-pipeline.yml) are the opposite: the consuming pipeline submits itself to a parent template that decides the overall structure. The parent owns the stages; the child only supplies parameters. An organization can publish an extends template to run required gates before and after a consuming team's build. This is a design pattern, not verified evidence about every Microsoft 1ES team. Enforcement additionally requires independently managed required-template checks on protected resources, restricted pipeline permissions and protected template/branch changes. A consumer can otherwise remove extends or use another deployment credential.
parameters: are the typed input contract. Unlike string variables (whose evaluation depends on syntax), parameters are typed (string, boolean, number, object, step, stepList, job, jobList, stage, stageList), validated at compile time, and expanded with the ${{ parameters.foo }} template-expression syntax. Template expansion occurs before the pipeline starts; this is what makes parameters useful for conditional ${{ if eq(parameters.deployProd, true) }} blocks that include or omit whole stages.
The non-obvious gotcha is expression syntax. Azure DevOps has three of them. $(var) is a macro expanded before a task executes. ${{ expr }} is a compile-time template expression evaluated when the YAML is processed, with parameters and static variables. $[ expr ] is a runtime expression used for variables/conditions with runtime variables but no parameter context; its evaluation boundary depends on where it is used. Conditions use expression functions directly. Mixing the syntaxes can cause a compile error, missing value or unintended condition, not only a silent boolean error. Read the docs once carefully and bookmark the page.
Good enough to ship
• templates/build.yml extracted, reused from one repo
• Typed parameters: for solution path and configuration
• Use ${{ if }} blocks to opt stages in or out
Expert tier
• Org-published extends plus protected-resource required-template checks; verify bypass attempts fail
• Template repo versioned via resources.repositories with pinned ref
• Parameter contracts documented; breaking changes shipped behind feature flags
Agent pools: Microsoft-hosted vs self-hosted
A pool is a set of agents (machines) that pick up jobs. Microsoft-hosted pools (vmImage: ubuntu-latest, windows-latest, macOS-latest) supply a fresh VM for each agent job with a generous tool catalogue pre-installed (.NET SDKs, Node, Python, common CLIs). They cost parallel-job slots and have public egress. Self-hosted pools are agents you install on your own VMs or in a VM Scale Set; they retain state between runs (warm caches, pre-pulled images), can reach your private network, and are one option for private connectivity or specialized hardware. Managed DevOps Pools and other supported agent offerings also need evaluation; private routing and hardware support are properties to verify, not implied by a pool name.
The choice is rarely binary. A team can start on Microsoft-hosted, switch a hot job (the one that downloads 4 GB of NuGet packages every run) to a self-hosted agent with a persistent package cache, and leave the rest. The trap is "let's move everything to self-hosted to save money" — you then own patching, scaling, isolation, and the inevitable "agent is offline" Slack message at 2am.
Demands are how a job declares it needs a particular capability (Agent.OS -equals Linux, or azurecli -exists). On a self-hosted pool, demands let you target a subset of agents (the ones with the GPU, the ones with the proprietary driver).
name: Microsoft-hosted; tagline: default; bullets: Fresh VM per agent job — Hosted maintenance — Private connectivity must be evaluated separately — Use for: most repos
name: Self-hosted VM; tagline: private network; bullets: Reaches private endpoints — Custom tooling installed — You own patching + scaling — Use for: enterprise integrations
name: Self-hosted VMSS; tagline: elastic at scale; bullets: Auto-scales agent count — Image lifecycle configured by the owner — Cost must be measured for the workload — Use for: platform teams
Authenticated package restore (NuGetAuthenticate)
Most enterprises host internal NuGet feeds on Azure Artifacts. Your pipeline must authenticate to those feeds before dotnet restore can fetch packages. The clean way is the NuGetAuthenticate@1 task — it configures the Azure Artifacts Credential Provider and authentication context for supported NuGet tools. Same-organization feeds use the pipeline identity with feed permissions; external feeds can require explicitly authorized service connections.
The legacy way (PAT in a NuGet config) still works and you will find it in older repos, but the PAT eventually expires, the renewal email goes to whoever set it up two years ago, and the pipeline starts failing at midnight on a renewal weekend. The task obtains run-time authentication for supported feeds; external service-connection credentials can still expire, and permissions, tool compatibility and network access can still fail.
steps:
- task: NuGetAuthenticate@1
- script: dotnet restore --configfile NuGet.config
displayName: Restore (authenticated)NPM has an authentication task (npmAuthenticate@0); Python upload uses Twine authentication, while pip restore needs its own supported feed authentication. These are analogous patterns, not interchangeable task contracts. The principle is identical: never store long-lived package-feed credentials in source.
Good enough to ship
• NuGetAuthenticate@1 before any restore step
• NuGet.config checked in, packageSourceCredentials NOT checked in
Expert tier
• Internal feed mirrors NuGet.org with vetted versions only
• package source mapping pins each package to its source
• Restore time tracked as a pipeline KPI
Artifacts: pipeline vs build, publish and consume
An artifact is the binary output of one stage that a later stage will consume. There are two flavours and the naming is confusing.
Pipeline artifacts (publish: keyword and the PublishPipelineArtifact@1 task) are the modern default. They are published into pipeline storage and are downloaded with download: current; artifact: <name> in deploy stages. They are scoped to the pipeline run.
Build artifacts (PublishBuildArtifacts@1) are the older artifact mechanism and useful for supported legacy/on-premises scenarios. Pipeline artifacts also support cross-pipeline consumption; that is not a reason by itself to choose build artifacts.
The cross-pipeline-and-pipeline-run flow uses pipeline resources: a release pipeline declares an upstream pipeline as a resource, and download: <pipelineName> pulls the version selected for that resource in this run. A completion trigger selects the triggering run; manual/scheduled selection follows resource version/branch/tag rules and the manual picker can override it. Record the actual run ID and digest, not an assumption of “latest”.
resources:
pipelines:
- pipeline: api-build
source: API.CI
trigger:
branches: { include: [ main ] }
stages:
- stage: Deploy
jobs:
- deployment: Deploy
environment: prod
strategy:
runOnce:
deploy:
steps:
- checkout: self
- download: api-build
artifact: api
- task: AzureWebApp@1
inputs: { ... }Good enough to ship
• publish: to emit a pipeline artifact
• download: current to consume in a later stage
• One artifact per deployable unit (api, web, infra-templates)
Expert tier
• Pipeline resources chain CI → release across repos • Artifact provenance recorded (commit SHA, build number, signer) • Set retention from rollback, audit and legal needs; a 90-day policy is only an example, not a platform maximum or default. Verify retained runs actually preserve the needed artifacts.
Environments, approvals, and gates
An environment in Azure DevOps is more than a string. It is a first-class object with its own resource page, deployment history, and checks that the pipeline must clear before a deployment job targeting that environment can run. Checks include manual approvals, business-hours windows, REST-call gates that ping a deployment-freeze service, Azure Monitor checks, evaluate-artifact policies (currently documented for container image artifacts only), and required-template enforcement on protected resources. A work-item/change-ticket check can be implemented through an approved REST/Azure Function integration; it is not automatically supplied by naming an environment.
The deployment history view is what makes incident response fast: every deployment is timestamped, linked to its commit, and attributed to the pipeline run that produced it. When prod breaks at 3am, the on-call engineer opens the environment page, sees "deploy 4271 ran at 02:51 UTC, commit a1b2c3", and has the suspect set in thirty seconds.
The gotcha is treating environments as decorative. Teams sometimes use one environment for all stages "for simplicity". A shared environment can still have checks and history, but it blurs deployment-stage ownership and applies the same checks to different targets. Separate dev/staging/prod where their policy or access differs, and protect the actual service connection as well: YAML can omit a decorative environment while still calling Azure with an available credential.
- stage: Deploy_Prod
jobs:
- deployment: DeployProd
environment: prod # ← approvals + business-hours gate attached in UI
strategy:
runOnce:
deploy:
steps: [ ... ]Good enough to ship
• One environment per stage (dev, staging, prod) • Prod approval policy is independently administered; listing a group does not require every group member to approve • Business-hours check uses an explicitly chosen timezone/window and timeout; it gates stage entry, not an automatic interruption of already-running deployment tasks
Expert tier
• REST gate calls deployment-freeze service before each prod stage
• Required-template check enforces extends lineage
• Approved REST/Azure Function integration verifies a change ticket; naming an environment does not add a built-in work-item gate
Test publishing and code coverage
Tests that pass silently are wasted. The pipeline must surface results to the run page so failures are findable and trends are trackable. The pattern is: run your test runner with a structured-output flag, then publish those results with the appropriate task.
- script: dotnet test -c Release --no-build --logger trx --collect "XPlat Code Coverage"
displayName: Test
- task: PublishTestResults@2
condition: always()
inputs:
testRunner: VSTest
testResultsFiles: '**/*.trx'
failTaskOnFailedTests: true
failTaskOnMissingResultsFile: true
failTaskOnFailureToPublishResults: true
- task: PublishCodeCoverageResults@2
condition: always()
inputs:
summaryFileLocation: '**/coverage.cobertura.xml'
failIfCoverageEmpty: truecondition: always() allows publishing after a preceding failure, including cancellation where the job remains runnable. It cannot resurrect a skipped parent job, lost agent or results never produced. The failure flags above separately reject failed tests, missing TRX and publication failure; failIfCoverageEmpty rejects missing required coverage. Use a .NET SDK setup task before the coverage task and install/configure a compatible collector (for this example, the test project needs the XPlat/coverlet collector). A successful empty test run can still need a minimum-test-count policy. The same pattern applies to npm: vitest run --reporter=junit --outputFile=test-results.xml then publish.
For coverage, the lesson is "track the trend, not the threshold". A hard 80% gate creates incentive to write low-quality tests that touch lines without asserting anything meaningful. A trend dashboard ("coverage dropped 5% on this PR — explain") makes the conversation about whether the loss is justified.
Good enough to ship
• TRX/JUnit publication attempted after test failure with condition: always(); missing results/publication failures are explicitly rejected
• Cobertura coverage summary published
• Failed tests fail the build
Expert tier
• Flaky-test detection (retry once, tag, report) • Coverage trend dashboard not a hard gate • Slow-test report surfaces the top 10 every run
Compliance gates: CodeQL, CredScan, BinSkim, antimalware, PoliCheck
Enterprise pipelines run a fixed catalogue of security and compliance scans. You should know each by name, by what it catches, and by how it integrates.
CodeQL (GitHub's static analysis engine) builds a queryable database of your code and runs vulnerability queries against it. Supported language/query packs can find paths such as injection or unsafe deserialization; they do not prove absence of every vulnerability. For the compiled C# path, initialize AdvancedSecurity-Codeql-Init@1 with the selected language, perform the instrumented build, then run AdvancedSecurity-Codeql-Analyze@1 in the intended job. A stale prebuilt binary or init after the build is not equivalent. Enable the licensed repository feature, retain analysis completion/results, and separately enforce the required release policy: publishing alerts is not automatically a severity-based build failure.
Secret scanning detects supported credential patterns. CredScan in the Microsoft Security DevOps extension was deprecated on 20 September 2023; use supported GitHub Advanced Security for Azure DevOps secret scanning or an approved maintained scanner. Alerts, push protection and a build-failing policy are different controls; configure and test the required control rather than assuming one implies the others.
BinSkim analyzes supported binary targets (the MSDO catalog lists Windows and ELF), including applicable compiler/linker hardening checks. Rule applicability differs by binary/tool version; diagnostics become release failures only under the configured policy. Do not assume every managed DLL has every native-code check.
Antimalware runs Defender (or a configured engine) over published artifacts. It can detect recognized malicious content, not prove an agent or artifact uncompromised. The MSDO catalog describes a Windows Defender-based antimalware tool; do not assume that tool runs on the Ubuntu jobs shown below.
PoliCheck is retained as an inherited example of terminology/policy scanning, not a verified available MSDO task for these pipelines. Tool availability, licensing and rules were not established here; obtain the organization's approved scanner/policy rather than copying an assumed task name. No universal Microsoft-internal deployment or effectiveness claim is made.
Optional tools in related categories (not drop-in equivalents, individually executed or licensed here) include Semgrep (CodeQL alternative), GitLeaks / TruffleHog (CredScan), Trivy / Grype (container scanning, dependency CVEs), Syft / CycloneDX (SBOM generation), and cosign (Sigstore signing).
How are these injected? The answer is the extends template again. The org publishes a pipeline template that runs the SDL gates before and after the consuming team's build stage. The consuming team writes a short azure-pipelines.yml that supplies parameters; the org's template owns the gate steps. Central policy reduces drift, but only when independently protected resources enforce the required template lineage and allowed inputs; editable consumer YAML alone is bypassable.
Good enough to ship
• Supported CodeQL + secret + dependency + container scanning and SBOM; licenses/tool support and failure policy are explicit • Validate repository-appropriate PR triggers; explicitly fail/hold release on policy violations, scanner failure or missing results for the exact artifact • Compatible approved antimalware on published artifacts in a supported execution environment
Expert tier
• All gates injected by extends template, not consumer YAML
• Custom CodeQL queries tuned for your domain
• SBOM signed; provenance attestation aligned with SLSA
Service connections: secret-based vs federated workload identity
A service connection is the credential your pipeline uses to talk to Azure (or any external system). Two common authentication models have different credential-lifecycle risks.
Secret-based service connections store a client secret (or certificate) for a service principal in Azure DevOps. The pipeline uses it to authenticate when calling Azure. The problem is the secret lives somewhere persistent — a compromised authorized agent or credential store can expose the resources accessible to that identity. Worse, secrets expire, the renewal lands on a single person's calendar, and rotation drift causes outages.
Federated workload identity (Workload Identity Federation, or WIF) removes the stored client secret for this connection. The configured issuer, subject and audience bind a federated assertion to the service connection identity; Entra exchanges the assertion for an access token. Copy the exact values supplied by the supported setup flow (issuer formats are evolving), not a guessed tenant/branch string. There is no stored client secret to rotate, but tokens have their own lifetimes and can be leaked by compromised agents or logs. Protect the agent and the connection permissions.
Use the supported automated setup/conversion flow where available, or its documented manual app-registration/user-assigned-managed-identity flow. Bind the exact issuer, subject and audience; grant the resulting identity the required Azure role; authorize only the intended pipelines. Conversion eligibility, directory/resource permissions and compatible tasks must be checked. No five-minute migration duration or universal CLI conversion support was verified. The 9 October 2026 documentation retires the old Azure DevOps issuer on 1 July 2027 for eligible public-cloud single-tenant/managed-identity connections; non-public clouds and multitenant applications are outside that stated deprecation scope. Follow the connection's actual supported conversion path.
- task: AzureCLI@2
inputs:
azureSubscription: 'svc-conn-fed' # ← Workload Identity Federation
scriptType: bash
scriptLocation: inlineScript
inlineScript: az group list --query "[].name" -o tsvGitHub Actions has an analogous OIDC pattern (azure/login@v2 with identifiers rather than a client secret), but also needs job OIDC permission and the correct repository/branch/environment trust. It is not Azure DevOps YAML. Prefer federation where the target service and task support it; document least-privilege, rotation and storage for unavoidable nonfederated credentials.
Good enough to ship
• Prefer supported federated Azure service connections; inventory and review any credential-based exceptions • Per-environment service principals (no shared one across dev/prod) • Least-privilege RBAC on each principal
Expert tier
• Restrict which pipelines can use the connection and apply protected-branch checks; the connection subject is not automatically a GitHub-style branch subject • If temporary role elevation is required, use an independently controlled, audited and tested mechanism; WIF alone neither grants Contributor nor revokes roles after deployment • Audit log streamed to a central SIEM for review
Secrets in pipelines: variable groups linked to Key Vault
Secrets that pipelines need (database connection strings, third-party API keys) belong in Azure Key Vault. Variable groups in the Azure DevOps Library can be linked to a Key Vault, so referencing $(MySecret) in YAML transparently reads the latest value from KV at run time. For an already mapped name, an accessible new value is read at runtime; adding/removing secret names requires updating the mapping. This integration maps secrets, not cryptographic keys/certificates. Vault permissions, firewall/private access and service-connection permissions are separate prerequisites.
Two iron rules. Never hardcode a secret in YAML — Git history is forever, and a supported detector may find it, but detection is not guaranteed; revoke/rotate exposed credentials and investigate rather than relying on masking or a revert. Never echo a secret in a script. Azure DevOps attempts masking of registered full secret values (not arbitrary substrings or transformed data), but if you pipe it to a file, write it to an artifact, or send it to an external service, it leaks. Treat any line containing $(SecretVar) as radioactive.
For credentials that cannot be federated, design rotation with the issuing service and consumers: create/activate the new credential, update the vault, verify consumers, then revoke the old one under a documented rollback plan. WIF can authenticate the vault writer, but writing an arbitrary new string into KV does not rotate the upstream API key. Scheduling, privileges and audit evidence remain separate responsibilities.
Good enough to ship
• KV-linked variable groups for all secrets • Secret values set through supported secret storage/UI, not plaintext YAML; explicitly map only to the task that needs them • Never expose secrets in logs/artifacts or send them to unrelated recipients; allow only the intended authenticated service flow
Expert tier
• Automated rotation pipeline reads from a source-of-truth and writes to KV • KV access reviews quarterly; orphaned identities removed • Soft-delete + purge protection on every KV
Secret-handling pitfall
Echoing $(SecretVar) in a script seems harmless because the log shows ***. Masking does not protect artifacts, files or outbound requests. A task-scoped environment mapping is the supported way to pass a secret to a reviewed script without command-line interpolation; do not echo it, dump the environment or send it to unrelated recipients.
Worked examples
A minimal CI + deploy pipeline
The first YAML builds/publishes; the following deployment fragment extends it. Configure Azure Repos branch-policy validation when applicable. Blocks using ..., external templates or smoke scripts are deliberate composition fragments, not complete pipeline definitions. Limit template path/artifact parameters to reviewed repository values; never expand untrusted queue-time text directly into shell commands. Deployment jobs need explicit checkout for repository scripts/templates. SDK 8.0.x is a historical version band, not a current patch-security recommendation.
# azure-pipelines.yml
trigger:
branches: { include: [ main ] }
pr:
branches: { include: [ main ] }
pool:
vmImage: ubuntu-latest
variables:
buildConfiguration: 'Release'
netVersion: '8.0.x'
stages:
- stage: CI
jobs:
- job: BuildAndTest
steps:
- task: UseDotNet@2
inputs: { version: $(netVersion) }
- task: NuGetAuthenticate@1
- script: dotnet restore
displayName: Restore
- script: dotnet build -c $(buildConfiguration) --no-restore
displayName: Build
- script: dotnet test -c $(buildConfiguration) --no-build --logger trx --collect "XPlat Code Coverage"
displayName: Test
- task: PublishTestResults@2
condition: always()
inputs: { testRunner: VSTest, testResultsFiles: '**/*.trx', failTaskOnFailedTests: true, failTaskOnMissingResultsFile: true, failTaskOnFailureToPublishResults: true }
- task: PublishCodeCoverageResults@2
condition: always()
inputs: { summaryFileLocation: '**/coverage.cobertura.xml', failIfCoverageEmpty: true }
- script: dotnet publish src/Api -c $(buildConfiguration) -o $(Build.ArtifactStagingDirectory)/api --no-build
displayName: Publish API
- publish: $(Build.ArtifactStagingDirectory)/api
artifact: apiWhat to notice:
UseDotNet@2selects the SDK version range shown; an exact patch/global.json is needed for exact pinning; never rely on the agent's pre-installed version.NuGetAuthenticate@1runs before restore — without the required authentication and feed permissions, a private-feed restore can fail; public-only restores have different requirements.--no-restoreand--no-buildon the downstream tasks avoid redundant work; measure the actual timing.- TRX + coverage published with
condition: always()so failures surface. publish:emits a pipeline artifact namedapifor downstream stages to consume.
Multi-stage deploy with environments and approvals
stages:
- stage: CI # as above
- stage: Deploy_Dev
dependsOn: CI
jobs:
- deployment: DeployDev
environment: dev
strategy:
runOnce:
deploy:
steps:
- checkout: self
- download: current
artifact: api
- task: AzureWebApp@1
inputs:
azureSubscription: 'svc-conn-dev'
appType: webAppLinux
appName: 'myapi-dev'
package: $(Pipeline.Workspace)/api
- stage: Deploy_Staging
dependsOn: Deploy_Dev
jobs:
- deployment: DeployStaging
environment: staging # ← approvals attached in UI
strategy:
runOnce:
deploy:
steps:
- checkout: self
- download: current
artifact: api
- task: AzureWebApp@1
inputs:
azureSubscription: 'svc-conn-staging'
appType: webAppLinux
appName: 'myapi-staging'
package: $(Pipeline.Workspace)/api
- script: ./tests/smoke.sh https://myapi-staging.azurewebsites.net
displayName: Smoke test
- stage: Deploy_Prod
dependsOn: Deploy_Staging
jobs:
- deployment: DeployProd
environment: prod # ← manual approval + business-hours check in UI
strategy:
runOnce:
deploy:
steps:
- checkout: self
- download: current
artifact: api
- task: AzureWebApp@1
inputs:
azureSubscription: 'svc-conn-prod'
appType: webAppLinux
appName: 'myapi-prod'
resourceGroupName: rg-myapp-prod
deployToSlotOrASE: true
slotName: 'staging'
package: $(Pipeline.Workspace)/api
- script: ./tests/smoke.sh https://myapi-prod-staging.azurewebsites.net
displayName: Verify staging slot before swap
- task: AzureAppServiceManage@0
inputs:
azureSubscription: 'svc-conn-prod'
Action: 'Swap Slots'
WebAppName: 'myapi-prod'
ResourceGroupName: rg-myapp-prod
SourceSlot: staging
SwapWithProduction: true
PreserveVnet: trueWhat to notice:
- Three environments, three service connections (each scoped to its own subscription/RG).
- Staging runs a smoke test against the deployed URL; staging is the last cheap place to catch regressions.
- Prod uses slot-swap: deploy to the staging slot, warm it, then swap. Swapping can restore code routing, but cannot undo database migrations or external side effects.
dependsOnchains stages; default success conditions prevent the next dependent stage on failure. An explicit condition replaces the default; includesucceeded()where required.
Reusable build template
# templates/build.yml
parameters:
- name: solution
type: string
- name: configuration
type: string
default: Release
- name: publishProject
type: string
- name: artifactName
type: string
default: app
jobs:
- job: Build
pool: { vmImage: ubuntu-latest }
steps:
- task: UseDotNet@2
inputs: { version: '8.0.x' }
- task: NuGetAuthenticate@1
- script: dotnet restore ${{ parameters.solution }}
- script: dotnet build ${{ parameters.solution }} -c ${{ parameters.configuration }} --no-restore
- script: dotnet test ${{ parameters.solution }} -c ${{ parameters.configuration }} --no-build --logger trx
- task: PublishTestResults@2
condition: always()
inputs: { testRunner: VSTest, testResultsFiles: '**/*.trx', failTaskOnFailedTests: true, failTaskOnMissingResultsFile: true, failTaskOnFailureToPublishResults: true }
- script: dotnet publish ${{ parameters.publishProject }} -c ${{ parameters.configuration }} -o $(Build.ArtifactStagingDirectory)/${{ parameters.artifactName }} --no-build
- publish: $(Build.ArtifactStagingDirectory)/${{ parameters.artifactName }}
artifact: ${{ parameters.artifactName }}# azure-pipelines.yml — consumes the template
trigger: { branches: { include: [ main ] } }
stages:
- stage: CI
jobs:
- template: templates/build.yml
parameters:
solution: 'src/MyApp.sln'
publishProject: 'src/MyApp.Api/MyApp.Api.csproj'
artifactName: 'api'What to notice:
- Parameters are typed; mis-spelling one fails at compile time.
- The template centralizes task versions and build flow; protected-resource checks are needed to prevent consumers replacing it.
- For org-wide enforcement, use
extendswith independently administered required-template checks, restricted parameters and protected template revisions.
Federated identity for a no-secret deploy
- stage: Deploy
jobs:
- deployment: ProdDeploy
environment: prod
strategy:
runOnce:
deploy:
steps:
- checkout: self
- download: current
artifact: api
- task: AzureCLI@2
displayName: Deploy infra
inputs:
azureSubscription: 'svc-conn-prod-fed' # Workload Identity Federation
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az deployment group create \
--resource-group rg-myapp-prod \
--template-file infra/main.bicep \
--parameters infra/prod.bicepparam
- task: AzureWebApp@1
inputs:
azureSubscription: 'svc-conn-prod-fed'
appType: webAppLinux
appName: 'myapi-prod'
package: $(Pipeline.Workspace)/apiWhat to notice:
- One service connection name, two consuming tasks — they share the same federated identity.
- No stored client secret for this WIF connection. Access tokens still exist; app secrets/certificates, other connections and federation trust/permissions can still need lifecycle management.
- The federation trust identifies the service connection. Restrict authorized pipelines and branches separately; another pipeline authorized to that connection can use its identity.
Hands-on exercises
-
Goal: Convert a classic-UI pipeline to YAML. Steps: (1) Pick a small repo with a classic pipeline. (2) Inventory supported export/migration paths; classic release definitions are not universally exportable as equivalent YAML. (3) Commit the YAML, point the pipeline to it, retire the classic definition. You're done when the YAML pipeline runs green and the classic one is disabled.
-
Goal: Extract a build template and reuse it across two pipelines. Steps: (1) Identify the common steps in two repos' pipelines. (2) Create
templates/build.ymlwithparameters:for the moving parts. (3) Point both pipelines at the template, supplying parameters. (4) Confirm both pipelines still produce identical artifacts. You're done when both pipelines consume the reviewed template revision; a pinned external ref must be deliberately advanced rather than silently following every change. -
Goal: Set up Workload Identity Federation for a service connection. Steps: (1) In Entra ID, find or create an app registration for the deploy. (2) Follow the supported flow to match the exact issuer, subject and audience, assign least-privilege Azure RBAC and restrict authorized pipelines. (3) In Azure DevOps, create the service connection with WIF. (4) Run a deploy pipeline; verify in Azure that the principal authenticated. You're done when the intended pipeline succeeds with that WIF connection and an unauthorized pipeline is denied; no real authentication result is claimed here.
-
Goal: Exercise supported security controls. Configure licensed CodeQL, dependency and secret scanning with explicit policy. Use a scanner-documented nonfunctional fixture in a disposable local/test repository, never a real key. Record whether it raises an alert, blocks a push or fails a build; then prove the intended release gate actually denies promotion. A made-up AWS-looking string may not match a real detector.
-
Goal: Configure a "prod" environment with manual approval + business-hours check. Steps: (1) In Azure DevOps → Environments, create
prod. (2) Configure the required independent approvals; a group listing alone can be satisfied by one member. (3) Add a "Business hours" check restricted to 09:00–17:00 local time, weekdays only. (4) Point a deploy stage at this environment. (5) Try to deploy at 18:30; confirm the gate blocks. You're done when an off-hours stage waits (or times out) under the configured timezone/window, an in-hours approved run can enter, and self-approval/resource-permission bypass controls behave as intended. -
Goal: Build a slot-swap deploy with auto-rollback. Steps: (1) Deploy to the staging slot. (2) Run a warmup HTTP request against the slot. (3) Swap slots. (4) Run a smoke test against production. (5) On smoke failure, stop later rollout and swap back only if the prior slot is preserved and the data/configuration remain backward-compatible; otherwise hold for the recovery procedure. You're done when a safe disposable fault triggers the selected recovery path and verification, including a blocked swap-back for an incompatible migration. The example YAML above does not implement this auto-recovery controller.
-
Goal: Author a multi-region rollout. Steps: (1) Define a canary stage targeting one region. (2) Use an illustrative 30-minute observation window with representative traffic, minimum sample count, error/latency criteria and ingestion delay accounted for; elapsed time alone is not a health check. (3) On healthy bake, fan out to wave1 (2 regions) and wave2 (remaining). (4) Wire each wave's success to be a precondition for the next. You're done when a bad canary blocks the wider rollout automatically.
Self-check questions
- Explain the difference between a stage and a job. Why split into multiple jobs within one stage?
- What does an "environment" give you that a stage doesn't?
- Why federated identity over a stored client secret? Name two concrete failure modes that disappear.
- What's the order: CI, SDL gates, deploy — and what would break if you reversed any pair?
- What's the difference between
runOnce,rolling, andcanarydeployment strategies, and when do you reach for each? - Why do path filters matter for monorepos, and what is the failure mode if you forget them?
- What's a variable group, and when do you link to Key Vault vs embed values inline?
- What's the difference between an
extendstemplate and a regular include template? - Why does
condition: always()belong on the test-publish task? - Walk through what happens, second by second, when a pipeline using Workload Identity Federation calls
az group list. - Name three SDL gates and what each catches that the others do not.
- Why is
script:a last resort compared to a task?
Worked assessment answers
- A stage is a dependency/approval boundary; jobs are execution units, usually on separate agents. Split build and deployment to change permissions or parallelize independent work; files do not carry over unless published/downloaded.
- An environment supplies deployment history and resource-owned checks. A YAML stage is not itself a protected credential or approval rule.
- WIF removes stored-client-secret expiry and rotation storage, not token theft or excessive permissions. The assertion identifies the configured connection; the resulting Azure token authorizes only its granted scope.
- Source analysis can run before compilation; compiled-language analysis surrounds the build; artifact/container scans follow packaging. All required gates must pass before promotion of the same artifact. Rebuilding after approval invalidates what was reviewed.
runOnceperforms one deployment lifecycle; use it for the App Service task sequence shown. Azure Pipelinesrollingsupport is for VM resources, not automatic App Service regional traffic shifting. Canary strategy describes incremented deployment lifecycle hooks; use it when a supported target and tasks can shift a small traffic fraction and verify health before expansion. The tasks/target system must implement traffic and health behavior. Naming a strategy does not supply a routing engine.- Path filters reduce irrelevant runs but an overly narrow filter can skip shared-library/template changes. Test changes in shared dependencies as well as service directories.
- Variable groups share configuration; Key Vault-linked groups retrieve selected secret names. New/deleted names require updating the group mapping. Non-sensitive values can be inline; secret contents cannot.
- Includes reuse content; extends lets a template own structure. Enforced lineage also needs resource checks and permissions outside editable YAML.
always()preserves test evidence after failure (subject to job/agent execution). It does not authorize deployment after a failed test. Keep success conditions on deployment stages.- The supported task obtains an assertion for its authorized WIF connection, Entra validates the exact issuer/subject/audience trust and issues an access token for the requested Azure resource/audience, and Azure checks token validity and RBAC for
az group list. No second-by-second timing is guaranteed; each boundary can fail independently. - Static analysis follows code flows; dependency scans compare component versions with advisories; secret scanners match credential patterns. An SBOM inventories components and is not a vulnerability scan. Each needs a configured disposition/exception policy.
- A maintained task can reduce bespoke integration code; a reviewed script is appropriate for custom logic. Neither is sandboxed simply by being a task or script.
Worked promotion and recovery exercise
Build 427 produces digest D. Dev and staging deploy D, staging smoke succeeds, prod checks approve D, and a slot swap exposes D. If prod health degrades, stop later waves, retain telemetry and decide whether the previous artifact remains schema-compatible. Swap-back restores routing only; a destructive migration is not undone. A canary with ten requests and no errors is insufficient evidence of safety: require representative traffic, minimum event count and complete telemetry. A timeout/missing telemetry outcome is hold, not success.
Exercise 1 solution: Inventory the classic pipeline's triggers, secrets references, permissions, artifacts, manual checks and failure behavior before translation. Run the YAML replacement against the same controlled commit and compare outputs and denied-deployment cases. Retire the classic definition only after that comparison; green status alone does not prove equivalent release controls.
Exercise 2 solution: Put common build logic in the template, with reviewed solution/project/configuration/artifact inputs. Compare manifests and digests for the same source/dependency inputs; reproducibility may need pinned toolchains/timestamps. Change one allowed parameter to verify it affects only the intended output; reject a shell-bearing untrusted parameter. Consumers pinned to an external revision change only when that pin is advanced.
Exercise 3 solution: Verify connection identity, exact issuer/subject/audience, pipeline authorization and resource-scoped RBAC independently. A valid assertion with missing RBAC must fail authorization; an unauthorized pipeline must not obtain use of the connection. Keep all token values out of diagnostics. The local trust/authorization model is not a real Entra exchange.
Exercise 4 solution: Use the scanner's documented nonfunctional fixture in an authorized disposable test repo, retain the detector output and bind policy to the exact commit/artifact. Verify alert versus push-block versus build/release denial separately. Also simulate missing/stale reports and tool failure: hold release, rather than treating no findings file as zero findings. Reverting a real credential would not revoke it.
Exercise 5 solution: Configure the resource-owned approver/self-approval rules and an explicit timezone, weekday window and timeout. Test denied/self approval, off-hours wait, timeout, in-hours success and a consumer YAML bypass against the protected connection. A listed group can be satisfied by one member; a window check gates entry and does not automatically stop tasks when the clock reaches 17:00.
Exercise 6 solution: Record artifact/slot identities, prove staging readiness and test production health after swap. Preserve the previous slot under deployment serialization. On failure, stop later waves; swap back only after schema/configuration compatibility checks, then reverify production. For an incompatible data change, hold and execute the migration-specific recovery plan, not a blind second swap.
Exercise 7 solution: Promote one immutable digest through canary, wave1 and wave2. Require complete fresh telemetry, sufficient representative requests and approved error/latency limits after the chosen bake interval. Low volume, missing telemetry or a stale digest means hold; a failed canary prevents both waves. The 30 minutes is an illustrative design input, not a validated universal detection time or an executed rollout.
For template reuse, compare manifests/digests and dependency inputs, not merely green run status. For classic migration, inventory triggers, permissions, variables and UI-only checks before switching; classic release pipelines are not universally exportable as an equivalent multi-stage YAML file. These exercises are source-backed design walkthroughs; no organization or cloud resources were changed.
High-signal resources
Official docs
- Azure Pipelines YAML schema — the canonical reference.
- Workload Identity Federation for service connections.
- Environments + approvals + checks.
- Templates: parameters and extends.
- Microsoft SDL practices.
- SLSA framework — supply-chain integrity levels.
Books or courses
Optional reading leads only: this review did not verify publisher editions, author attributions or the scope/causal interpretation of book results. Verify those details in the selected publisher edition; these entries do not support pipeline API claims or promise a completion duration.
- Continuous Delivery — attributed here to Jez Humble and David Farley; bibliographic details are unverified in this review. Assess older tooling examples against current platform documentation rather than assuming every chapter remains operationally current.
- Accelerate — attributed here to Nicole Forsgren, Jez Humble and Gene Kim; author/edition and specific study claims remain unverified here. Correlation is not proof that a particular pipeline causes a business outcome.
Practitioner posts
- The DevOps Handbook publisher page — optional book lead; full author list/edition and practice-specific attribution were not verified here.
- Honeycomb blog — an index, not a verified named CI/CD incident article; select and verify a specific post before citing it.
- The Twelve-Factor App — optional methodology lead; its individual propositions were not used as primary evidence in this review.
- Microsoft DevOps Resource Center — discovery index, not proof of any task input or service behavior above.
Weekly milestones
Suggested study allocation only; durations are unverified and setup, access approvals and debugging can require additional time.
- Day 1: Read the YAML schema overview + the pipeline anatomy section. Author a hello-world pipeline with a trigger, one stage, one job, two scripts. Answer self-check 1, 2, 9.
- Day 2: Add a real build: dotnet restore/build/test, publish test results, publish an artifact. Run on PRs and on main. Answer self-check 6, 12.
- Day 3: Extract a
templates/build.yml. Consume from a second pipeline. Read the templates docs end to end. Answer self-check 8. - Day 4-5: Add Deploy_Dev, Deploy_Staging, Deploy_Prod with environments and a manual approval on prod. Migrate the prod service connection to Workload Identity Federation. Answer self-check 3, 4, 5, 10.
- Day 6-7: Add supported CodeQL, secret scanning, dependency/container scanning and SBOM. Wire them into your
extendstemplate if you have one, otherwise into the build stage. Answer self-check 7, 11.
How it shows up in the capstone
A proposed capstone implementation would ship with azure-pipelines.yml and a templates/ folder. The root pipeline declares CI + Deploy_Dev + Deploy_Staging + Deploy_Prod stages; templates/build.yml owns the dotnet flow; an extends template (imagined to live in a shared platform repo) wraps everything in SDL gates. Supported Azure service connections would use federation; this does not make every downstream credential federated. Required application secrets would use explicitly mapped vault names and verified access/network rules. The protected resource must reject a pipeline that omits required template lineage.
The proposed prod environment has independently configured approvals (verify the intended reviewer count), a business-hours check, and a REST gate that pings a deployment-freeze service. The prod stage deploys to a staging slot, runs warmup and smoke tests, then swaps with production. A second pipeline triggers on the build-pipeline resource and runs a multi-region rollout for the rare cases where a single-region App Service is not enough.
When you can describe each of those decisions and defend why they are not over-engineering for the capstone's scope, you have the chapter.
Previous chapter → Ch 17 — Caching
Next chapter → Ch 19 — IaC + rollout orchestration
Corrected contracts and failure analysis
Environment approvals and checks live outside YAML under resource-administrator control. A pipeline author cannot secure a deployment merely by adding a comment saying approval required. Separate code-review permissions from environment/service-connection permissions and restrict who can change those checks. A federated service connection still needs an explicit Azure role assignment at the minimum scope.
Build once, publish an immutable artifact and record its digest; deployment should consume that artifact rather than rebuilding unchecked code on the release agent. Pin tool patch versions deliberately: 8.0.x selects a band, not one exact SDK. Publish failures with always() without using continueOnError to turn failed tests into release eligibility. Coverage measures executed statements, not assertion quality. Hosted-agent images and marketplace security tasks vary; do not assume Microsoft-internal scanners or licenses are universally available.
Boundary exercise with solution
A PR changes YAML to skip a scanner but still names environment prod. Where must the policy be enforced?
Solution and reasoning
At the protected resource: an independently administered required-template/check and approvals, with least-privilege service connection and protected branches. Test an intentional bypass attempt in a disposable pipeline. The existence of a YAML stage is not evidence its security policy cannot be bypassed.
Source-backed review notes
Official/maintainer snapshots were retrieved and hash-checked on 7 October 2026. The review evidence retains exact supporting passages, document dates and hashes; mutable documentation is not a pin to an installed service/runtime version.
-
pr definition | Microsoft Learn — Azure Repos PR validation uses branch policy, not YAML pr alone.
-
Pipeline deployment approvals - Azure Pipelines | Microsoft Learn — Resource administrators own checks outside editable pipeline YAML.
-
Set a Resource Manager workload identity service connection - Azure Pipelines | Microsoft Learn — Use current issuer/subject flow; old Azure DevOps issuer has a documented retirement, not a universal permanent issuer.
-
NuGetAuthenticate@1 - NuGet authenticate v1 task | Microsoft Learn — External feeds need configured service connections; NuGetAuthenticate is not an immortal credential.
-
Resources in YAML pipelines - Azure Pipelines | Microsoft Learn — Resource version selection depends on trigger/default/manual picker; a resource declaration is not a complete environment compatibility gate.
-
AzureAppServiceManage@0 - Azure App Service manage v0 task | Microsoft Learn — Swap task requires Azure connection, app, resource group and source slot with documented production/target inputs.
-
AzureWebApp@1 - Azure Web App v1 task | Microsoft Learn — AzureWebApp requires appType and slot resource-group inputs where applicable.
-
Set secret variables - Azure Pipelines | Microsoft Learn — Masking is not a confidentiality boundary; explicitly map secret environment variables, not secret command-line interpolation.
-
Configure the Microsoft Security DevOps Azure DevOps Extension - Microsoft Defender for Cloud | Microsoft Learn — MSDO CredScan is deprecated; use a supported secret scanner and separately configured failure policy.
Current pipeline evidence boundaries
Checked 9 October 2026: hosted agents are fresh per agent job; test publishing has separate failed/missing/publication-error switches; coverage publishing has a missing-coverage switch and SDK prerequisite; CodeQL setup describes initialize/build/analyze and licensed repository enablement; Key Vault variable groups map selected secret names, not keys/certificates. Saved raw/canonical quotations support these contracts; local models do not reproduce Azure's service evaluator, IAM, scanner runtime or deployment.
Pause / Recall / Apply
Can you explain it without the page?
Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.
Stored in this browser only. No account, no sync. Clearing browser data removes your record.