Conditional Steps for Skipping Work Intelligently
There’s a bug sitting in the pipeline from two chapters ago, and it’s been invisible the whole time because every test run so far has passed. The moment a real test fails, it surfaces — and it takes out exactly the thing you’d most want working at that moment: the test report.
The Default Behavior: One Failure Skips Everything After It
Here’s the rule GitHub Actions follows unless you tell it otherwise: if a step fails, every step after it in that job is cancelled. Not run-and-ignored — cancelled entirely, as if they weren’t in the workflow file at all.
Most of the time, that’s the behavior you want. If checkout fails, there’s no point running a step that assumes the code is there. But look at where “Upload Test Report” sits in your build job:
- name: Run Tests
run: python -m pytest --junitxml=test-report.xml
- name: Upload Test Report
uses: actions/upload-artifact@v7
with:
name: test-report
path: test-report.xmlpytest writes test-report.xml as part of running — win or lose, the file exists on disk by the time the Run Tests step finishes. But if a test fails, that step’s exit code is non-zero, which counts as a step failure. And a step failure skips everything after it — including “Upload Test Report.” The one time you actually need that report — a run where something broke — is exactly the run where it never gets uploaded.
Watching It Happen
Break a test on purpose to see it. Edit send_file.py and change something that causes tests to fail. For example bind port twice because test expects binding only once:
# other contents same
sock.bind((host, port)) # Bind to the port
sock.bind((host, port)) # Bind Twice to See Failure
# other contents sameThe assertion in tests/test_send_file.py that flags the above double bind is:
sock.return_value.bind.assert_called_once()Add and commit the changes:
git add tests/test_send_file.py
git commit -m "Temporarily break a test to observe step skipping"
git pushOpen the Actions tab. “Run Tests” fails, as expected — but look at “Upload Test Report” right below it: it shows as cancelled, not run. Scroll to the Artifacts section at the bottom of the run summary, and test-report isn’t there at all. The one report you’d actually want to inspect after a failure is nowhere to be found.
build (3.13, macos-latest)
The strategy configuration was canceled because "build._3_12_ubuntu-latest" failed
build (3.13, macos-latest)
The operation was canceled.The Fix: continue-on-error: true and if: always()
continue-on-error: true for a step means the workflow will continue regardless of failure in that step. This is required for our Run Tests because we are and we have to expect tests will fail sooner or later in out Software Development Lifecycle (SDLC):
- name: Run Tests
run: python -m pytest --junitxml=test-report.xml
continue-on-error: truealways() is a status-check function that evaluates to true regardless of whether earlier steps in the job passed, failed, or anything else. Put it on the step you don’t want cancelled or skipped:
- name: Upload Test Report
if: always()
uses: actions/upload-artifact@v7
with:
name: test-report
path: test-report.xmlWith this in place, “Upload Test Report” runs no matter what happened in “Run Tests” — success or failure. The artifact gets uploaded either way, because getting the report out is valuable precisely because something might have gone wrong, not only when nothing did — exactly what we need here.
Important
always() really does mean always — it overrides the default failure-skips-everything-after behavior entirely, even if an earlier step in the job errored out completely (not just a failing test, but something like the checkout step itself failing).
For a step like this one, that’s fine, since Upload Test Report only depends on a file existing, and gracefully has nothing to upload if it doesn’t. Be more careful reaching for always() on a step that assumes something specific from an earlier step actually succeeded — you don’t want to always() your way into running a step against data that was never produced.
A Note On Job-Level Skipping
This same default applies one level up, between jobs, not just between steps. Your summarize job declares needs: build — and by the same rule, if build fails, summarize is cancelled entirely, not just the step that happens to depend on the artifact. If you wanted summarize to still attempt its work even when build fails (say, to report “tests failed, no data available” instead of not running at all), you’d add the same always() function to the job’s if: condition:
summarize:
needs: build
if: always()
runs-on: ubuntu-latestThis project doesn’t need that — summarize’s whole job is to read the report build uploads, and with the step-level fix above, that report now gets uploaded whether or not the tests passed, so summarize can run normally and report an accurate failure count. It’s worth knowing the job-level version exists, though, since it’s the exact same trap one layer up, and it won’t announce itself any louder than the step-level one did.
Hands-On: Confirming The Fix
Update .github/workflows/ci.yml with if: always() on the upload step:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: ["3.12", "3.13", "3.14"]
os: [windows-latest, ubuntu-latest, macos-latest]
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
cache-dependency-path: requirements.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Cache Ruff Cache
uses: actions/cache@v6
with:
path: .ruff_cache
key: ruff-${{ runner.os }}-${{ hashFiles('**/*.py') }}
restore-keys: |
ruff-${{ runner.os }}-
- name: Lint (ruff)
uses: astral-sh/[email protected]
- name: Run Tests
run: python -m pytest --junitxml=test-report.xml
# Force workflow to continue even if test fails (because test can fail)
continue-on-error: true
- name: Upload Test Report
# Upload Test Report always regardless of previous step failure
if: always()
uses: actions/upload-artifact@v7
with:
name: test-report-${{ matrix.os }}-${{ matrix.python-version }}
path: test-report.xml
summarize:
needs: build
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: ["3.12", "3.13", "3.14"]
os: [windows-latest, ubuntu-latest, macos-latest]
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
- name: Download Test Report
uses: actions/download-artifact@v8
with:
name: test-report-${{ matrix.os }}-${{ matrix.python-version }}
- name: Parse Report And Write Job Summary
env:
PYTHONIOENCODING: utf-8
run: python scripts/summarize_report.py >> "$GITHUB_STEP_SUMMARY"
shell: bashPush it, with the broken test still in place:
git add .github/workflows/ci.yml
git commit -m "Upload test report even when tests fail"
git pushThis time, “Upload Test Report” runs despite “Run Tests” failing, the artifact shows up in the run’s Artifacts section, and summarize runs on schedule afterward, producing a job summary table that correctly shows one failed test — with its name listed, straight from the report that almost didn’t make it off the runner.
Now revert the deliberate failure:
git revert HEAD~1
git pushYour pipeline now does the right thing in both directions: a passing run and a failing run both leave you with a report you can actually look at.
Two jobs in this workflow now repeat the same setup — checkout, install Python, in both build and summarize. The final chapter in this section folds that duplication into a single reusable piece.