'Construct a condition in Azure DevOps using 'each'

In Azure DevOps (YAML pipeline), we have a stages that should be run only after another set of stages have been skipped.

In the example below, the parameter copyStages_UAT can be amended by users when triggering a manual run, meaning it's impossible to hard-code the dependsOn and condition properties, so necessitating the use of the directive each.

- template: ../Stages/stage--code--depoly-to-environment.yml
  parameters:
    name: Deploy_PRD_UKS
    displayName: Deploy PRD - UK South
    dependsOn:
    - ${{ each uatStage in parameters.copyStages_UAT }}:
      - Roll_Back_${{ uatStage.name }}
    variables:
    - template: ../Variables/variables--code--global.yml
    - template: ../Variables/variables--code--prd.yml
    environment: PRD

This stage above works in a pipeline, however because a successful run results in stages defined in dependsOn being skipped, sadly then Azure DevOps will also skip this stage.

To counter this, I'm trying to add a condition to check whether or not the previous stages were all skipped.

condition: >-
  and(replace(
    ${{ each uatStage in parameters.copyStages_UAT }}:
      eq(dependencies.Roll_Back_${{ uatStage.name }}.result, 'Skipped'), 
  ), ', )', ' )')

Unfortunately though, it seems as though I cannot use the directive each in this context -

The directive 'each' is not allowed in this context. Directives are not supported for expressions that are embedded within a string. Directives are only supported when the entire value is an expression.

As condition can only be a string, how can I leverage expressions and/or directives to construct my desired condition?

Example of desired YAML

Assuming the following value was given for the parameter copyStages_UAT -

- name: UAT_UKS
  displayName: UAT - UK South
- name: UAT_UKW
  displayName: UAT - UK West

This is how the YAML should be compiled. I'm not worried out the format of the condition, as long as the relevant checks are included.

- template: ../Stages/stage--code--depoly-to-environment.yml
  parameters:
    name: Deploy_PRD_UKS
    displayName: Deploy PRD - UK South
    dependsOn:
    - Roll_Back_UAT_UKS
    - Roll_Back_UAT_UKW
    condition: >-
      and(
        eq(dependencies.Roll_Back_UAT_UKS.result, 'Skipped'),
        eq(dependencies.Roll_Back_UAT_UKW.result, 'Skipped')
      )
    variables:
    - template: ../Variables/variables--code--global.yml
    - template: ../Variables/variables--code--prd.yml
    environment: PRD


Solution 1:[1]

Updated:

To summary your demand, you are looking for a expression that you can use it in condition while the dependsOn value are dynamic. And this stage should run only after another set of dependent stages are all skipped.

As far as I know and tested, this can not be achieved via each.

For further confirmation, I discussed this scenario with our pipeline PM who is more familiar with each and YAML pipeline.

Same as me, he also think this isn't possible to achieve. Because ${{ each }} expects to be the outermost part of a mapping key or value, so you can’t nest it into a condition string.


Work around:

You could fake it by having a hard-coded stage which depends on the dynamic list, figures out if they were all skipped, and sets an output variable. Then the real final job would only depend on that “decider” job, and its condition would depend on the contents of the output variable.

Figuring out that all upstream dependencies were skipped is something of an exercise for you. You might be able to dynamically construct one step per stage. Those steps map the stage’s status to a variable with a known name scheme. Then the final (hardcoded) step iterates the environment variables of the known name scheme and decides whether the next stage should proceed.

And... yes, I am aware how ugly that sounds ?

Solution 2:[2]

Azure DevOps Pipelines does not have a particularly good way for solving this. However, and(...), join(delimiter, ...) and 'Filtered Arrays' can be used to hackily accomplish this.


Observe that the following condition could be rearranged:

and(
  eq(dependencies.Roll_Back_UAT_UKW.result, 'Skipped'),
  eq(dependencies.Roll_Back_UAT_UKX.result, 'Skipped'),
  eq(dependencies.Roll_Back_UAT_UKY.result, 'Skipped'),
  eq(dependencies.Roll_Back_UAT_UKZ.result, 'Skipped')
)
and(
  eq(dependencies.Roll_Back_
  UAT_UKW
  .result, 'Skipped'), eq(dependencies.Roll_Back_
  UAT_UKX
  .result, 'Skipped'), eq(dependencies.Roll_Back_
  UAT_UKY
  .result, 'Skipped'), eq(dependencies.Roll_Back_
  UAT_UKZ
  .result, 'Skipped')
)

Or more abstractly, where PREFIX=eq(dependencies.Roll_Back_ and SUFFIX=.result, 'Skipped'):

and(
  <PREFIX>
  UAT_UKW
  <SUFFIX>, <PREFIX>
  UAT_UKX
  <SUFFIX>, <PREFIX>
  UAT_UKY
  <SUFFIX>, <PREFIX>
  UAT_UKZ
  <SUFFIX>
)

With filtered arrays (NAMES=parameters.parameterName.*.name) to extract the name, an aggregation could then be written:

and(<PREFIX>${{ join('<PREFIX>, <SUFFIX>', <NAMES>) }}<SUFFIX>)

Thus:

condition: |
  and(
    ne(dependencies.Roll_Back_${{ join('.result, ''Skipped''), ne(dependencies.Roll_Back_', parameters.copyStages_UAT.*.name) }}.result, 'Skipped')
  )

But there are some obvious caveats with this:

  • If there is 0 elements in parameters.copyStages_UAT, then the expression would evaluate to and(dependencies.Roll_Back_.result, 'Skipped') which could be non-sensical.

  • and(...) requires a minimum of 2 arguments, so this could potentially fail if there is only 0 or 1 expression(s). To circumvent this, True can be supplied as the first and second argument such that the expression is always valid. If your logic instead requires or(...), then use False instead of True to keep the meaning consistent.

Therefore, you may need to protect against these scenarios occurring with a modified check:

${{ if eq(length(parameters.copyStages_UAT), 0) }}:
  condition: false
${{ else }}:
  condition: |
    and(
      True,
      ne(dependencies.Roll_Back_${{ join('.result, ''Skipped''), ne(dependencies.Roll_Back_', parameters.copyStages_UAT.*.name) }}.result, 'Skipped')
    )

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2