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