Skip to content
SRE & DevOpsDeep Dive Published Updated 9 min readViews unavailable

Azure Pipelines Expressions: Compile-Time Templates, Runtime Conditions, and Macro Expansion

Choose Azure Pipelines expression phases correctly, build conditional YAML, diagnose stale values, and keep template inputs within clear trust boundaries.

Azure Pipelines has three expression syntaxes that look similar but run at different times: template expressions (${{ }}), runtime expressions ($[ ]), and macro variables ($(name)). Choosing the wrong form can produce a pipeline that silently uses an old value, cannot see a queue-time parameter, or tries to change YAML structure after the pipeline plan has already been created. The practical rule is to choose the phase from the decision: template expressions shape the plan, runtime expressions evaluate conditions and values during execution, and macros substitute variable values before a task starts.

These mechanisms are related, but they are not interchangeable aliases. In particular, a runtime condition does not remove a stage from the compiled plan, and a template conditional does not wait for a task to set a variable. Treat the pipeline as a two-part contract: compile a valid plan from reviewed structure and parameters, then allow that plan to execute only when runtime state and prior results satisfy the intended conditions.

The three phases at a glance

Syntax Example Resolution point Best fit
Template expression ${{ parameters.target }} During template expansion and plan initialization Typed parameters, reusable YAML, conditional insertion
Runtime expression $[eq(variables['Build.SourceBranch'], 'refs/heads/main')] At runtime when a value or condition is evaluated Runtime-derived variables and conditions
Macro variable $(Build.BuildNumber) Before a task executes Supplying a variable to a task input or script step

The difference is observable. A template expression sees parameters and statically available variables while Azure Pipelines expands the YAML. It cannot read a value that a running job has not produced yet. Runtime expressions have access to more runtime variables but not to template parameters. Macro syntax is expanded immediately before a task runs; it is useful for task inputs, but it does not turn a runtime value into a pipeline keyword or dynamically rebuild the plan.

Variables are strings. If a value needs a declared type and validation, use a parameter rather than assuming a variable is Boolean, numeric, or a safe enumeration. Parameters have explicit data types, and string parameters can constrain values; the actual guarantee still depends on the parameter’s type and validation shape.

Use parameters to select structure, not to imitate a runtime check

Template conditionals add or omit YAML while the plan is being expanded. This is appropriate when a reviewed, typed choice changes which stage or job exists in a run. A runtime condition is appropriate when a stage should exist in the plan but run only if branch, dependency, or output state allows it.

parameters:
- name: includeDocs
  type: boolean
  default: false
- name: deployTarget
  type: string
  default: staging
  values:
  - staging
  - production

stages:
- stage: Build
  jobs:
  - job: Compile
    steps:
    - script: ./ci/build.sh

- ${{ if eq(parameters.includeDocs, true) }}:
  - stage: Documentation
    jobs:
    - job: Generate
      steps:
      - script: ./ci/build-docs.sh

- stage: Deploy
  condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
  jobs:
  - job: Publish
    steps:
    - script: ./ci/deploy.sh "${{ parameters.deployTarget }}"

Here, Documentation is inserted only when the Boolean parameter is true. Deploy remains in the plan, but the runtime condition allows it only on the main branch and after dependencies succeed. deployTarget has a bounded set of values so callers cannot accidentally supply an arbitrary target name. This sample is illustrative: replace the scripts, branch policy, and target names with controls actually enforced in the project. A YAML condition is not a substitute for resource permissions or protected-resource checks.

The two decisions must not be conflated. If the intent is “do not create a job in this run,” use template insertion driven by a parameter or a variable that is genuinely available during expansion. If the intent is “the job exists but should be skipped unless runtime state is true,” use condition. A job skipped at runtime may still appear in the plan and UI, which can be useful for explaining why a release did not execute.

Why updated variables look stale

Template expressions are expanded before steps execute. If a task later sets a variable through a logging command, ${{ variables.name }} retains the value that existed during compilation. A later task using $(name) can see the updated value because macro syntax is evaluated before each task. This is intentional: template expansion cannot retroactively rewrite the plan after a job begins.

variables:
  mode: initial

steps:
- bash: |
    echo "##vso[task.setvariable variable=mode]updated"
  displayName: Set runtime variable

- bash: |
    echo "template=${{ variables.mode }}"
    echo "macro=$(mode)"
  displayName: Compare expansion phases

The expected distinction is template=initial and macro=updated. Do not use this pattern to pass secrets through template syntax: compile-time substitution places the expanded value in the pipeline plan and logs may make the resulting YAML inspectable. Use the platform’s secret-variable mechanisms and task inputs designed for secret values, and avoid echoing them.

Runtime expressions are useful for defining a runtime-derived variable, particularly from variables such as Build.SourceBranch that contain periods:

variables:
  isMain: $[eq(variables['Build.SourceBranch'], 'refs/heads/main')]

The index form is required for a name such as Build.SourceBranch; dot-property syntax works only for variable names that satisfy the documented identifier rules. Runtime expressions used as a variable value must occupy the entire right-hand side. For example, isMain: $[ ... ] is valid, but adding ordinary text before or after the expression is not the supported form.

Conditions and success semantics

A custom condition controls whether a job, stage, or step runs. When a condition replaces the default, include the success behavior you intend. For an ordinary deployment gate, and(succeeded(), <runtime test>) makes the success requirement explicit. succeeded() is false after cancellation and when relevant dependencies have failed; using only a branch comparison could allow execution after a failed dependency, depending on the graph and scope.

Use status functions deliberately. always() can evaluate true even after cancellation, although a critical failure may still prevent a task from starting. It is appropriate for some cleanup or evidence-collection steps, not as a default for production mutation. For an action that must not run after cancellation, use a condition that explicitly excludes cancellation. Test stage and job behavior with failed, skipped, and canceled dependencies rather than checking only a green run.

There is a related distinction between a compile-time ${{ if }} and a task-level condition:. The first inserts YAML based on data available during expansion. The second evaluates when the already-created stage, job, or step is scheduled. Avoid placing a queue-time or task-produced runtime variable inside template logic and expecting it to restructure the plan.

Scope limits and safe composition

Template expressions are not supported everywhere in a YAML document. Microsoft documents expansion for stages, jobs, steps, and containers inside resources, but not for trigger definitions or repository resources such as repositories. Pipeline keywords that determine the source or trigger must be configured through their supported static syntax; substituting a macro or expression there will not make those fields dynamically evaluate later.

Keep reusable templates narrow. Declare parameter names, types, defaults, and allowed values; quote strings where YAML type inference could change a value; and reject unexpected inputs rather than silently converting them. An object, stepList, or jobList parameter can carry a large YAML structure, so its type alone does not make supplied content trustworthy. An extends template can constrain allowed pipeline structure, but security depends on enforcing its use through protected resources and controlling who can edit or approve the trusted template. A parameter that accepts executable steps is intentionally granting the caller influence over execution.

Do not interpolate attacker-controlled text into an inline script body and assume YAML parameter typing provides shell escaping. If a value must reach a process, pass it through a documented task input or environment variable and quote it according to the target shell’s rules. Restrict which pipelines can reach protected service connections and environments independently of expression logic. Expression syntax helps define a plan; it does not authenticate the author or authorize a deployment.

Debug the expanded plan before a release

When a pipeline unexpectedly skips work, inspect the evaluated condition and dependency status, then confirm that the referenced variable exists at that phase and scope. For a stale value, locate where the variable is set and compare its expansion time with the task that consumes it. Azure’s macro expansion leaves an unresolved reference as $(name); if that text is placed in a script, the target shell may interpret it according to its own syntax rather than display it literally. Missing template and runtime expression variables can instead resolve to an empty string, which can look like a valid but incorrect configuration.

Validate the pipeline against a small matrix before promoting a template change:

  • Compile with each allowed parameter value and reject an invalid enum value.
  • Run with a non-main branch, then with the protected branch; verify the runtime gate independently of template insertion.
  • Set a variable in one task and prove that a later macro sees it while the already-expanded template expression does not change.
  • Exercise failed, skipped, and canceled dependencies to verify the exact condition semantics.
  • Remove or misspell an expected variable in a disposable pipeline and confirm that the resulting empty or unresolved value fails closed rather than selecting production by accident.
  • Inspect the rendered pipeline plan for unexpected jobs, stages, script bodies, or secret material before authorizing a real deployment.

Use a project-specific test pipeline for these probes. A successful YAML parse only shows that the document can be parsed; it does not prove that expressions resolve at the intended phase or that the resulting plan enforces the release policy.

The reliable model is simple: parameters and template expressions decide what the pipeline contains, runtime expressions and conditions decide when that plan executes, and macro variables provide current values to tasks. Making those phases explicit turns “why is this value stale?” into a traceable contract, while separate identity and protected-resource controls remain responsible for deciding who may change or deploy production.

Related:

Sources:

Comments