Skip to main content

Step conditions

The if field on a step controls whether that step runs. It holds a boolean expression that is evaluated when the step is reached, after the steps before it have finished. If the expression is true the step runs; if it is false the step is skipped - it does not run and does not fail its job, and later steps still evaluate their own if.

jobs:
release:
steps:
- id: build
run: ./build.sh
- name: Upload logs
if: always() && steps.build.state == 'FAILED'
run: ./upload-logs.sh
- name: Publish
if: steps.build.outputs.artifact == 'ready'
run: ./publish.sh

The same if field, with the same expression language, is available on jobs - see Job conditions. A step condition differs only in what it can reference: it is scoped to its own job's steps, not to other jobs.

Expression syntax

An if value is a boolean expression written directly in the field. It is not wrapped in ${{ }}: the ${{ }} form is string substitution (see Expressions) and is a different mechanism. A condition is evaluated to true or false, not substituted into text.

if: always() && steps.build.state == 'FAILED'

${{ }} is not interpreted inside if and is rejected during validation. Reference values directly (steps.build.state), not as ${{ .steps.build.state }}.

Operators

OperatorMeaning
==equal
!=not equal
&&logical and
||logical or
!logical not
( )grouping

Precedence, from highest to lowest: !, then == and !=, then &&, then ||. Use parentheses to make grouping explicit:

if: always() && (steps.build.state == 'FAILED' || steps.test.state == 'FAILED')

Literals

  • Strings, in single quotes: 'SUCCEEDED', 'ready'.
  • Booleans: true, false.
  • Integers: 3, 0.

Bracket form for references

A name that is a plain identifier - letters, digits, and underscores - can be written directly after a dot: steps.build.state. A name containing other characters, such as a hyphen, is written as a quoted name in brackets:

if: always() && steps['lint-code'].state == 'FAILED'

The two forms are equivalent; steps.build.state and steps['build'].state reference the same step. The bracket form works for any reference, so it also covers inputs['...'] and env['...'] names that are not plain identifiers.

Values you can reference

Steps run sequentially, so each step implicitly depends on the steps before it in the same job - there is no step-level depends_on, and none is needed. A step condition is therefore scoped to its own job's steps.

inputs.<name>

A workflow input. See Workflow Inputs.

if: inputs.environment == 'production'

env.<NAME>

An environment variable visible to the step. See Environment Variables.

if: env.STAGE == 'prod'

steps.<id>.state

The terminal state of an earlier step in the same job. <id> must be the id of a step that runs before this one; referencing a later step or a step without an id is a validation error.

The possible state values are:

ValueMeaning
'SUCCEEDED'the step completed successfully
'FAILED'the step failed
'SKIPPED'the step was skipped (its own condition was false)

Comparing a step to 'SUCCEEDED' duplicates the success requirement, so steps.<id>.state is most useful together with always() or failure() - which lift that requirement - to branch on which earlier step reached a given state:

steps:
- id: build
run: ./build.sh
- name: Upload logs
if: always() && steps.build.state == 'FAILED'
run: ./upload-logs.sh

steps.<id>.outputs.<name>

An output value produced by an earlier step (see Step outputs), for value-based branching. A referenced output that was not produced is an empty string.

steps:
- id: detect
run: echo "changed=true" >> "$H2O_WORKFLOWS_OUTPUT"
- name: Publish
if: steps.detect.outputs.changed == 'true'
run: ./publish.sh

Status functions

Two functions summarize the outcome of the steps before this one in the same job.

failure()

True when an earlier step failed, and it lifts the success requirement so the step can run despite that failure. A step marked continue_on_error that fails does not trip failure() - its failure is tolerated - but its state is still observable as steps.<id>.state == 'FAILED'.

always()

Always true. The step runs regardless of how earlier steps turned out (short of the job being canceled).

steps:
- run: ./build.sh
- name: Cleanup
if: always()
run: ./cleanup.sh

The success requirement

By default, a step runs only when every earlier step in its job succeeded. Adding an if condition keeps that success requirement and applies it together with the condition: the step runs when no earlier step failed and the condition is true.

The failure() and always() functions are the exception: using either one lifts the success requirement, so the step can run even after an earlier step failed. This is what makes in-job cleanup and notification steps possible. Any other condition is applied on top of the requirement:

ConditionRuns when
(no if)no earlier step failed
if: steps.x.outputs.ready == 'true'no earlier step failed and the condition is true
if: trueno earlier step failed (an always-true condition does not lift the requirement)
if: failure()an earlier step failed
if: always()any outcome (the success requirement is lifted)

A step marked continue_on_error that fails does not fail its job, does not trip failure(), and does not hold the success requirement for later steps.

Scope

A step condition sees only the other steps in its own job. Another job's state (deps.<id>.state) belongs to the job condition and is not available in a step condition; to reach a step at all, its job has already passed its own condition. The same if field works on the steps inside a composite action, scoped to that action's own inputs, env, and prior steps.

Skipped steps

A step whose condition excludes it ends in the terminal state SKIPPED. A skipped step:

  • does not run and does not fail its job;
  • does not stop later steps from evaluating their own if;
  • is observable to later steps as steps.<id>.state == 'SKIPPED'.

See Failure handling for how skipping interacts with continue_on_error.

Validation

Conditions are checked when the workflow is saved:

  • the expression must be syntactically valid;
  • every steps.<id> must name an earlier step in the same job (a job-scope deps.<id> reference is not allowed in a step condition);
  • referenced inputs and env names must exist.

Examples

Branch within a job on a computed output, and always clean up:

jobs:
release:
steps:
- id: detect
run: echo "changed=$(git diff --quiet HEAD~1 && echo false || echo true)" >> "$H2O_WORKFLOWS_OUTPUT"
- name: Publish
if: steps.detect.outputs.changed == 'true'
run: ./publish.sh
- name: Skip notice
if: steps.detect.outputs.changed == 'false'
run: echo "nothing to publish"
- name: Cleanup
if: always()
run: ./cleanup.sh

Run a step only when an earlier step failed:

steps:
- id: build
run: ./build.sh
- name: Upload build logs
if: failure()
run: ./upload-logs.sh

Combining job and step conditions

Job and step conditions compose: a job-level if decides whether the job runs at all, and step-level if decides the flow within it. Here recover runs only because of its job-level if: failure() (the build job failed); once it runs, its own steps branch with step conditions. See Job conditions for the job-scope details.

jobs:
build:
steps:
- id: compile
run: ./compile.sh
- name: Upload logs
if: always() && steps.compile.state == 'FAILED' # step condition
run: ./upload-logs.sh

recover:
depends_on: [build]
if: failure() # job condition: runs because build failed
steps:
- id: diagnose
run: ./diagnose.sh
- name: Page on-call
if: steps.diagnose.outputs.severity == 'high' # step condition inside a conditionally-run job
run: ./page.sh

Feedback