Thuta Learning
IntermediateDevOps & Toolsbeginner

Conditional Steps and Jobs

What you'll walk away with

  • Explain the core ideas behind Conditional Steps and Jobs
  • Read the diagram and trace how the pipeline flows and what gates each stage
  • Read the workflow file and predict which jobs run, in what order, and what gates them

Build the mental model

if: decides whether a step or a job runs. Written on a step it is evaluated after the previous steps have run, so it can read their results. Written on a job it is evaluated once the jobs in needs: have settled. The value is an expression, and inside if: the wrapping braces are optional, because GitHub evaluates the string as an expression either way. You need the wrapper when the expression starts with a character YAML would misread, which is why if: ${{ !cancelled() }} has it and if: matrix.coverage does not.

The part that actually matters is the implicit default. Every step behaves as though written if: success(): it runs only while no previous step in the job has failed. The moment a step fails, every later step without an explicit condition is skipped. That default is right for the build itself and completely wrong for the things you want most when a build breaks: the test report, the screenshot bundle, the teardown that releases a leased resource. Those need if: always() or if: ${{ !cancelled() }}, and a team usually discovers this the day a flaky end-to-end suite fails and there is no report to look at, because the upload step was skipped exactly when it mattered.

always() also runs when a run is cancelled, which is why !cancelled() is usually better for cleanup: it still runs on failure but does not fight a cancellation someone requested on purpose.

continue-on-error is different again. A step that fails with it set records an outcome of failure but a conclusion of success, so the job keeps going and success() stays true for the steps after it.

text
WHICH STEPS RUN ON THE SUCCESS AND FAILURE PATHS
------------------------------------------------
  step (in order)              all green        step 3 fails
  ---------------------------  ---------------  ---------------
  1 checkout                   runs             runs
  2 npm ci                     runs             runs
  3 npm run test:e2e           runs, passes     runs, FAILS
  4 npm run lint               runs             SKIPPED
      continue-on-error: true
  5 upload screenshots         SKIPPED          RUNS
      if: failure()
  6 upload junit report        runs             RUNS
      if: always()
  7 teardown                   runs             RUNS
      if: !cancelled()

  job result:                  success          failure

  a step with no if: behaves as if: success()
  a continue-on-error step that fails does NOT flip success()
  on a CANCELLED run: step 6 still runs, step 7 does not

Connect it to a real scenario

Take an end-to-end suite that writes JUnit XML into reports/ and failure screenshots into test-results/. The naive workflow puts the upload step last with no condition, which means it runs on every green build, where you do not need it, and is skipped on every red one, where you do.

Fix it by giving each follow-up step the condition that matches its purpose. The screenshot upload gets if: failure(), because screenshots only exist and only matter when something went wrong. The JUnit report upload gets if: always(), because the report is worth having in both directions and a test summary tool needs it either way. Teardown gets if: ${{ !cancelled() }} so that a leased database or a deployed preview environment is released whether the tests passed or failed, while still respecting a deliberate cancel.

Combine conditions where a step is genuinely conditional. if: ${{ failure() && github.event.pull_request.draft == false }} notifies the team about broken non-draft pull requests and stays quiet on drafts, which is how you keep notifications worth reading.

The lint step uses continue-on-error: true. It reports its own red mark in the step list, so nobody can pretend the warnings do not exist, but it does not fail the job, and crucially it does not flip success() to false for the steps that come after it.

Try the working example

yaml
name: End-to-end tests with reports

on:
  pull_request:

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Run end-to-end tests
        id: e2e
        run: npm run test:e2e

      - name: Lint (advisory only)
        continue-on-error: true
        run: npm run lint

      - name: Upload failure screenshots
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: screenshots
          path: test-results/
          if-no-files-found: ignore

      - name: Always upload the test report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: junit-report
          path: reports/junit.xml
          if-no-files-found: warn

      - name: Notify only on a real failure
        if: ${{ failure() && github.event.pull_request.draft == false }}
        run: ./scripts/notify.sh "e2e failed on ${{ github.head_ref }}"

      - name: Release the leased environment
        if: ${{ !cancelled() }}
        run: ./scripts/teardown.sh
You should see
On a fully passing run every step executes except the failure-only ones: the screenshot upload and the notification are skipped, the JUnit upload and the teardown run, and the job result is success. When the end-to-end step fails, the steps after it that carry no condition are skipped, the lint step among them, while the screenshot upload runs because failure() is true, the JUnit upload runs because always() is true, the notification runs if the pull request is not a draft, the teardown runs because the run was not cancelled, and the job result is failure. If lint itself fails, its own entry is marked failed but the job continues and the overall job result is still success, because continue-on-error converts that step's conclusion to success. If someone cancels the run, the always() steps still execute while the !cancelled() teardown does not, which is precisely the difference between the two conditions.

5-minute try-it

Take a workflow with a report-upload step at the end and make its test step fail on purpose. Confirm the upload is skipped. Add if: always() and confirm it now runs. Then add a cleanup step with if: ${{ !cancelled() }}, cancel a run from the UI, and observe which of the two conditions still fired. Finally, mark a lint step continue-on-error: true and check what the job's overall conclusion becomes.

One important caution

Leaving a test-report or screenshot upload with no if:, so it inherits the implicit success() and is skipped on exactly the failed runs you needed it for.

Reading continue-on-error as 'ignore this step': the step still shows as failed in the UI and its outcome is failure, only the job's conclusion is left unaffected.

GitHub Docs - ExpressionsCI/CD with GitHub Actions

Easy traps

  • Leaving a test-report or screenshot upload with no if:, so it inherits the implicit success() and is skipped on exactly the failed runs you needed it for.
  • Reading continue-on-error as 'ignore this step': the step still shows as failed in the UI and its outcome is failure, only the job's conclusion is left unaffected.
  • Validate a workflow on a branch or test repository before pointing it at a production deployment.

Exercise

Take a workflow with a report-upload step at the end and make its test step fail on purpose. Confirm the upload is skipped. Add if: always() and confirm it now runs. Then add a cleanup step with if: ${{ !cancelled() }}, cancel a run from the UI, and observe which of the two conditions still fired. Finally, mark a lint step continue-on-error: true and check what the job's overall conclusion becomes.

You'll know it worked when: On a fully passing run every step executes except the failure-only ones: the screenshot upload and the notification are skipped, the JUnit upload and the teardown run, and the job result is success. When the end-to-end step fails, the steps after it that carry no condition are skipped, the lint step among them, while the screenshot upload runs because failure() is true, the JUnit upload runs because always() is true, the notification runs if the pull request is not a draft, the teardown runs because the run was not cancelled, and the job result is failure. If lint itself fails, its own entry is marked failed but the job continues and the overall job result is still success, because continue-on-error converts that step's conclusion to success. If someone cancels the run, the always() steps still execute while the !cancelled() teardown does not, which is precisely the difference between the two conditions.

Conditional Steps and Jobs | Thuta Learning