Pipeline-as-Code Foundations
Last reviewed · content updated
BeginnerWhat 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 testRead 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 fromLesson 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 whyThen 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.
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.
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.