Orchestrator Pipelines and Required Template Checks¶
Why Azure DevOps' CheckRequiredTemplate check silently never matches an orchestrator-shaped
pipeline, and what cloud-foundations changed so it does.
Status: fixed for cloud-foundations, not a universal recipe
azure-pipelines.yml and the 9 .azdo/prs/*.yml PR-preview pipelines now extends the
platform's required template directly instead of reaching it through a local orchestrator
include. Read If your repo looks like this
before copying the mechanical steps into another repo — Forge's orchestrator shape breaks
this approach outright.
If your repo looks like this, read this first¶
This guide applies to a specific shape: one or more local orchestrator template files holding
a shared deployments map, included into entry pipelines via stages: - template:. If that's
your repo, the fix in The fix
below is directly applicable.
If your orchestrator is instead a machine-maintained inventory generator — many PR-preview
pipelines, each one listing the entire inventory rather than a single deployment, wired up
with generated # BEGIN[...] / # END[...] blocks and ${{ each }} loops — stop. Inlining and
trimming does not work for that shape; see
This does not generalize to every orchestrator
before doing anything.
The problem¶
Azure DevOps lets you attach a CheckRequiredTemplate check to a service connection, which is
meant to guarantee that only pipelines built from an approved template can use the connection.
The Required Template section of the approvals and checks docs
states the enforcement plainly: "a pipeline fails if it doesn't extend from the referenced
template." That word is load-bearing. The check matches extends:, not any other way a
template's code ends up running inside a pipeline.
Azure Pipelines has two unrelated mechanisms that both get called "templates," and the template types documentation draws the line between them explicitly:
- an includes template "includes the template's code directly in the outer file... similar
to
#includein C++" — this is whatstages: - template:,jobs: - template:, andsteps: - template:do - an extends template "defines the outer structure of the pipeline" — this is what a
root-level
extends:does
CheckRequiredTemplate only recognizes the second kind. A pipeline that reaches the required
template file through an include chain never satisfies the check, no matter how deeply nested
or how faithfully it reproduces the template's content.
It's also not something you can route around by having your own local template extend the
required one. The extends schema reference
is explicit that "a pipeline can extend a single template" and that extends is a root-level
pipeline construct — a template file itself can never contain an extends: block. So "just
have our local orchestrator template extend the platform template" isn't a workaround; it's not
expressible in the schema at all. The only thing that can extend a template is the pipeline
file itself, at its root.
Why this bites orchestrator-shaped repos specifically¶
cloud-foundations — and, on the same platform, Mosaic (Azure DevOps' SAIF project) and
Forge/Smithy (saif-corp/forge) — use a "global orchestrator" pattern: one or more local
template files (here, .azdo/templates/*-orchestrator.yml) hold a shared deployments map,
and several entry pipelines each pull it in with stages: - template: <local-orchestrator>.
The main pipeline runs the whole map; PR-preview pipelines each pass a filter parameter to
run just one deployment for validation.
That's an include chain end to end. The entry pipeline includes the local orchestrator via
stages: - template:, and the local orchestrator in turn includes the platform's real
orchestrator template the same way
(stages: - template: /v2/orchestrator.yml@templates, from SAIF/pipeline-templates) — it
does not extends: it; per the schema reference above, a template file can't contain an
extends: block at all. extends never appears anywhere in this chain, at any depth — so
CheckRequiredTemplate, which only inspects the entry pipeline's own root, has nothing to
match. Any service connection this shape uses simply cannot carry a required-template check.
The fix: extend the required template directly, and inline the map¶
The fix is to make every entry pipeline satisfy the schema constraint directly: move
extends: to the pipeline's own root, pointing at the platform template, and fold the local
orchestrator's parameters body straight into that pipeline's extends.parameters. The local
orchestrator template files are then deleted, since nothing references them anymore.
Before (.azdo/prs/devtools.yml, going through the local global-orchestrator.yml include):
resources:
repositories:
- repository: templates
type: git
name: SAIF/pipeline-templates
ref: main
stages:
- template: ../templates/global-orchestrator.yml
parameters:
enablePreview: true
filter:
deployments:
- devtools
After (extending the platform template directly, with just the devtools deployment inlined):
resources:
repositories:
- repository: templates
type: git
name: SAIF/pipeline-templates
ref: refs/heads/releases/v3
extends:
template: /v2/orchestrator.yml@templates
parameters:
projectId: cloud-foundations
preview:
enabled: true
environments:
- production
deployments:
devtools:
environments:
- production
config:
service: terraform
type: workspace
terraformPath: infra/devtools
organization: SAIF
workspaceName: azure-devtools-infrastructure
TF_VAR_service: devtools
TF_VAR_owner: saif
TF_VAR_deployed_by: $(Build.RequestedFor)
TF_VAR_build_number: $(Build.BuildNumber)
environments:
- name: production
displayName: "Production"
provider: saif
environment: production
environmentGroup: global
dependsOn: []
Each of the 9 PR-preview entry pipelines was already scoped to exactly one deployment via a
filter.deployments parameter before this change, so only that one deployment's config needs
inlining, not the whole map. Trimming a deployment out of the deployments map compiles
identically to leaving it in with enabled: false — an omitted key and a disabled key both
just don't emit a job. And any dependsOn entry pointing at a deployment that was never
emitted (for example shared_services's dependsOn: [tfc_config, identity, devtools], when
only shared_services itself is inlined) is silently dropped by the orchestrator rather than
failing the compile. That makes this trim-to-one-deployment step behavior-preserving. The
filter parameter and its containsValue(...) enable guards are removed entirely from these
files, since a map with one deployment needs no filtering.
The main entry pipeline (azure-pipelines.yml) and the global entry pipeline
(azure-pipelines-global.yml) drop filter too, even though each still inlines its full
multi-deployment map. Both are trigger: none / pr: none — nothing in the repo ever queues
either one with a non-default filter.deployments, and no CI/CD path populates it via
templateParameters. The parameter's only real effect was letting an operator selectively
re-run one deployment on a manual queue; these two pipelines are meant to always run everything
on every invocation, same as before this change, so every enabled: ${{ or(containsValue(...))
}} guard collapses to a bare enabled: true.
azure-pipelines.yml drops its environments parameter for the same reason and inlines the
list directly under extends.parameters, matching what azure-pipelines-global.yml already
did. Its teams parameter stays, because that loop genuinely generates 16 deployments from 8
rows rather than being dead indirection. The nested ${{ each env in parameters.environments }}
filter inside that loop is gone though: a team on environmentgroup: all now just gets
environments: ['all'], which is the deployment orchestrator's own sentinel for "every
environment stage" (containsValue(deployment.environments, 'all') in
/v2/deployment-orchestrator.yml), and product teams get the four product environments
listed explicitly. Note that the platform's general environmentGroup filter is a pipeline-wide
parameter on /v2/orchestrator.yml — azure-terraform-infrastructure-owner-v2.yml defaults it
to product, for instance — so it can't be used here, where one pipeline deploys both a
platform-group team and seven product-group teams.
Also pin the templates repo ref to the release line the check names¶
CheckRequiredTemplate's repositoryRef for SAIF/pipeline-templates is
refs/heads/releases/v3. Any pipeline extending /v2/orchestrator.yml@templates has to
declare that exact same ref in its own resources.repositories entry — not refs/heads/main,
and not the shorthand main some of the older PR pipelines still use. If the ref doesn't
match, the check does not pass even when the extends shape is otherwise correct, because the
check is validating which commit of the template file was actually used, not just its path.
Separately, templatePath in the required-template check's configuration is an exact
repository-relative file path, not a glob, and — unlike the pipeline-side
extends.template: /v2/orchestrator.yml@templates syntax — it takes no leading slash:
v2/orchestrator.yml is the value the check API and Terraform/Pulumi provider examples show
(see azuredevops_check_required_template's docs); something like "*.yml" is not a valid
value and will not match anything either way.
The real cost of this approach, stated honestly¶
The shared deployments map stops being declared once. Before this change, one local
orchestrator template held the full map and every entry pipeline reused it with a lightweight
filter. After this change, the main entry pipeline and each of the 9 PR-preview files each
hold their own slice of that map, inlined separately.
Nothing enforces that those slices stay in sync. Adding a new global deployment now means editing the main pipeline's inlined map and creating a new PR-preview file with its own inlined copy of that one deployment's config — two places to update by hand, with no compile-time check tying them together. That's a genuine maintainability regression relative to the single-template-plus-filter shape. It's tolerable here specifically because each PR-preview slice is exactly one deployment, so the duplication is small and easy to eyeball against the main file during review.
This does not generalize to every orchestrator¶
Mosaic (Azure DevOps' SAIF project) and Forge/Smithy (saif-corp/forge) share this same
"local orchestrator include" pattern and will hit this identical wall the day someone attaches
a required-template check to a connection either of them uses. Neither should assume this
doc's mechanical steps just work.
Forge's case is the sharper warning. Its orchestrator is an inventory generator: 27 PR-preview
pipelines, all drawing from the same machine-maintained inventory, wired together with
generated # BEGIN[...] / # END[...] marker blocks and ${{ each }} loops. Critically, every
one of those 27 pipelines' deployments already lists the entire inventory, not one
deployment each — there is no single-deployment slice to inline the way cloud-foundations' PR
pipelines had. Applying this doc's approach there would mean duplicating the full inventory
into all 27 files, replacing one generated source of truth with 27 hand-synced copies of it.
That's not a maintainability regression, it's a different category of problem.
If Forge (or any inventory-generator-shaped orchestrator) needs to satisfy a required-template
check, the honest options are narrower: keep the check off connections that inventory-generator
pipelines need and lean on other controls for those connections, or find a way to keep the
generator emitting a root-level extends: per pipeline without hand-duplicating the inventory —
which is a real engineering problem, not a documentation copy-paste. Don't reach for the
inline-and-trim pattern here and assume it will behave the same way it did for
cloud-foundations' one-deployment-per-PR-pipeline shape.
The residual security caveat¶
CheckRequiredTemplate constrains which template file a pipeline extends. It says nothing
about what parameters that pipeline calls the template with — and this fix's whole mechanism is
inlining the deployments map, including arbitrary config like terraformPath, directly into
files that live on a branch.
Azure DevOps compiles a PR build's entry pipeline from the PR's own branch, pre-merge, and it
only withholds service connection access from fork PRs — not same-repo branch PRs. So a
same-repo PR that edits one of these inlined .azdo/prs/*.yml files to change
terraformPath, or point a deployment at a different workspace, still passes
CheckRequiredTemplate (it's still extending the right template) while running with whatever
config that PR branch declares.
That means CheckRequiredTemplate is not, by itself, a defense against a same-repo PR steering
a deployment somewhere malicious. CODEOWNERS enforcement on .azdo/** is the control that
actually closes that gap — requiring review from people who own the pipeline configuration
before any change to these files can merge or run. Treat CODEOWNERS on .azdo/** as a required
complement to this pattern, not optional hardening layered on top of it.
Scope note¶
Not every pipeline in a repo needs to extend a required template — only the ones that actually
use a connection the check is attached to. .azdo/prs/tfc-sentinel.yml in cloud-foundations is
a plain jobs:-shaped pipeline with no orchestrator include and no service connection in it, so
it's out of scope for this change and was left untouched.