Modern DevSecOps Foundations Module 2 · Pipelines as a Product

Pipeline-as-Code Foundations

Last reviewed · content updated

Beginner

What you'll learn

~20 min
  • Author a multi-stage YAML pipeline with stages, jobs, and steps in the right roles
  • Distinguish compile-time from runtime expressions - the #1 source of pipeline confusion
  • Gate deployments with environments and approvals instead of hope

One sentence of history, then work

Pipelines used to be configured by clicking. The click-built kind still exists in Azure DevOps (“classic” pipelines) and its docs are still maintained — but the honest 2026 status is not deprecated, strategically dead: new orgs default to classic-creation-disabled and every ounce of platform investment goes to YAML. All new work is pipeline-as-code: the pipeline lives in the repo, changes by PR, and is reviewed like everything else. (That property — reviewable like everything else — is what Module 2 builds its whole product on.)

The anatomy, with each level doing one job

trigger:
branches: { include: [main] }
stages: # STAGES: sequence + gate boundaries
- stage: build
jobs: # JOBS: parallelism + agent assignment
- job: build_and_test
pool: { vmImage: ubuntu-latest }
steps: # STEPS: the actual work, in order
- script: npm ci && npm test
- task: PublishPipelineArtifact@1
inputs: { targetPath: dist, artifact: webapp }
- stage: deploy_test
dependsOn: build
jobs:
- deployment: deploy # DEPLOYMENT job: targets an environment
environment: meridian-test
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: webapp
- script: ./deploy.sh test

Read the roles, not just the syntax: stages are where sequencing and approval gates live; jobs are the unit of parallelism and agent selection; steps do work. And the deployment job is not a fancy job — it registers the run against an environment object, which is where deployment history accrues and, crucially, where approvals and checks attach. Gate the environment, and every pipeline that deploys to it inherits the gate — configured once, enforced everywhere. That inversion (protect the destination, not each pipeline) is the platform-thinking seed the rest of this module grows.

The concept that explains most pipeline confusion

YAML pipelines run in two phases, and expressions belong to one or the other:

COMPILE TIME ${{ }} - template expansion, BEFORE any agent exists.
Parameters live here. Conditional stage/job
INCLUSION happens here. What you see in the
expanded-YAML view is this phase's output.
RUNTIME $[ ] (orchestrator, for job/stage conditions and variables)
and $(var) (expanded on the agent) - as the run executes.
Variables set by earlier steps live here.
Conditions on job/step EXECUTION happen here.

The classic confusion — “my ${{ if }} can’t see the variable my script just set” — is a phase error: the if was resolved before any script existed. The rule of thumb that survives: parameters and structure are compile-time; variables and outcomes are runtime. Templates (next lesson) are entirely compile-time machinery, which is why they compose so predictably — by the time the run starts, they’ve already vanished into plain stages and steps.

Prompt first

Build the pipeline for Meridian's crew-scheduling app (the contract from
Lesson 1.3 says: pipeline family = web-app standard):
- CI stage on PRs and main: install, unit tests, publish artifact
- deploy stages: test then production, each a deployment job against a
named environment; production requires manual approval + business-hours
check attached to the ENVIRONMENT, not the pipeline
- one parameter (deployTarget) demonstrating a compile-time choice, and
one variable passed between steps demonstrating runtime flow - with a
comment at each marking WHICH phase evaluates it and why
Then show me the expanded-YAML view and point at where each ${{ }} vanished.

That last request is the learning device: seeing your own template expressions already resolved in the expanded view makes the two-phase model click faster than any diagram.

ℹOn GitHub: the same anatomy, different names

GitHub Actions maps cleanly: workflow ≈ pipeline, job ≈ job (with needs for sequencing — there is no stage level), step ≈ step; environment: on a job attaches the same protection-rule machinery (required reviewers, wait timers); expressions are all runtime-flavored ${{ }} with inputs/needs context rather than a two-phase model — simpler to reason about, less powerful for structural composition. The composition story diverges enough that it gets its own callout in the next lesson.

KNOWLEDGE CHECK

A pipeline sets a variable in a script step ('##vso[task.setvariable variable=needsMigration]true') and a later stage begins: '${{ if eq(variables.needsMigration, true) }}: - stage: migrate'. The migrate stage NEVER runs, even when the script clearly set the variable. Why?

Key takeaway

Pipelines are code: stages gate, jobs parallelize, steps work, and deployment jobs bind runs to environments — where approvals live so the destination is protected once for every pipeline. Master the two phases (structure at compile time, outcomes at runtime) and most pipeline mysteries dissolve. Next: stop writing pipelines per-team and start composing them from a product.

Search lessons