Modern DevSecOps Foundations Module 2 · Pipelines as a Product

The Three-Tier Template Model

Last reviewed · content updated

Intermediate

What you'll learn

~20 min
  • Structure a template library in three tiers: atomic steps, composing stages, copy-me starters
  • Use stepList injection so stage templates own scaffolding while consumers own steps
  • Consume a central template repo with pinned references

From forty pipelines to one product

By the fifth team, Meridian’s platform crew has read forty YAML files that are 80% identical and 20% divergent in ways nobody chose deliberately. The fix is the module title: pipelines become a product — a template library teams consume — and the architecture that’s proven out in a real software factory has exactly three tiers:

TIER 1 STEP TEMPLATES ~dozens of atomic, single-responsibility units,
(steps/*.yml) grouped by concern: build, security-scan, deploy,
health-check, package, evidence. Each does ONE
thing and exposes typed parameters.
TIER 2 STAGE TEMPLATES the composition layer: own the stage scaffolding
(stages/*.yml) (timing, wrapping, always-appended health/report
steps) and SPLICE IN consumer-provided steps.
TIER 3 STARTER PIPELINES extends targets that LIVE IN THE TEMPLATE REPO
(starters/*.yml) (so 2.3's required-template check can see them);
the copy-me part is the ~20-line consumer file
that extends one and stays ~20 lines forever.

The mechanism that makes tier 2 work: stepList injection

The elegant move — worth understanding deeply because it’s the template-method pattern rendered in YAML — is the stage template accepting a list of steps as a parameter and splicing it into scaffolding it owns:

# stages/deploy-stage.yml (tier 2)
parameters:
- name: environmentName
type: string
- name: deploySteps
type: stepList # <- the injection point
default: []
stages:
- stage: deploy_${{ parameters.environmentName }}
jobs:
- deployment: deploy
environment: ${{ parameters.environmentName }}
strategy:
runOnce:
deploy:
steps:
- template: ../steps/record-start.yml # scaffold: owned
- ${{ each step in parameters.deploySteps }}: # consumer steps -
- ${{ step }} # see governance note
- template: ../steps/health-report.yml # scaffold: ALWAYS
# appended, not optional

Read the division of authority: the stage owns the frame — timing capture, the environment binding, and the health-report step that every deployment emits whether the consumer remembered it or not — while the consumer owns only the middle: which steps actually deploy their thing. A team cannot forget the health report, because forgetting isn’t expressible. That’s the same design instinct as compliance-baked-into-modules (coming in Module 3) and it’s the whole reason tier 2 exists: scaffolding you can’t opt out of, flexibility exactly where teams differ.

Consumption side, a starter pipeline stays honest-sized:

# a product team's azure-pipelines.yml (from a tier-3 starter)
resources:
repositories:
- repository: templates
type: git
name: platform/pipeline-templates
ref: refs/tags/v2.4.0 # PINNED - next lesson explains why
extends: # governance hook - also next lesson
template: starters/web-app.yml@templates
parameters:
environments: [test, prod]
deploySteps:
- script: ./deploy.sh $(target)

Two details to notice now and study next lesson: the template repo is consumed by pinned tag (@templates + ref:), so a template change never silently rewrites forty teams’ pipelines; and the consumer extends a starter rather than including fragments — the hook that governance will hang from.

Prompt first

Build tier 1 and tier 2 of our template library:
- Step templates: npm-build, container-build, security-scan (stub for now -
Module 3 fills it), deploy-webapp, health-check, record-timing. One
responsibility each, typed parameters, no environment assumptions.
- One stage template: build-stage (owns timing + artifact publish, injects
buildSteps) and one deploy-stage as sketched above (owns environment
binding + always-appended health-report, injects deploySteps).
- One tier-3 starter: web-app.yml wiring build + deploy-to-two-environments,
with TODO markers where a team must choose.
Then convert Meridian's crew-scheduling pipeline from Lesson 2.1 to consume
the starter - the diff should DELETE most of that file.

The conversion diff is the proof of the product: a forty-line bespoke pipeline collapsing to a twenty-line starter consumption means the divergence that remains is chosen, not accidental.

ℹOn GitHub: reusable workflows compose differently

The GitHub equivalents are reusable workflows (callable whole-workflow units via uses:) and composite actions (step bundles). The honest difference: there’s no stepList-style injection — a reusable workflow can’t splice caller-provided steps into its own scaffolding, so composition happens by parameterizing inputs or chaining jobs, and “scaffolding you can’t opt out of” is enforced with required workflows/rulesets instead of splice points. Same goals, different grain: GitHub governs at the workflow boundary, Azure Pipelines composes inside the stage.

KNOWLEDGE CHECK

A product team asks for the deploy-stage template to make the health-report step optional: 'our service has no health endpoint yet, and the failing report step is blocking deploys.' What's the platform-product answer?

Key takeaway

One governance note the splice demands: consumer-injected steps execute inside the deployment job, with its service connection — ancestry alone does not constrain what they do with it. The reference pattern (Microsoft’s own ‘security through templates’ guidance) iterates parameters.deploySteps at compile time and fails the template on disallowed step or task types, so the scaffold owns not just the wrapping but the allowlist of what may be wrapped.

Three tiers: atomic steps, stage templates that own un-opt-outable scaffolding and inject consumer step lists, and copy-me starters that keep team pipelines twenty lines long. Consumers pin the template repo by tag and extend starters — two threads the next lesson pulls into versioning and governance. The product’s promise: divergence only where it’s chosen.

Search lessons