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.
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 notConnect 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
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
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 - Expressions — CI/CD with GitHub Actions