Skip to content

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 #include in C++" — this is what stages: - template:, jobs: - template:, and steps: - 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.