Skip to main content
GitHub Actions · lesson 1 of 7
GitHub Actions beginner Lesson 1 of 7

Also part of: DevOps Git & GitHub

GitHub Actions: Workflows, Jobs, and a Real Failing Step

Run a real GitHub Actions workflow locally with act — no push, no account needed — and see exactly what a failing step's exit code does to the rest of the job.

By SkillByExample · Updated

github-actions ci-cd act workflows

Every real GitHub Actions run can be reproduced on your own machine — no push, no account — with act, which runs the actual workflow YAML against real Docker containers.

Installing act

curl -s https://raw.githubusercontent.com/nektos/act/master/install.sh | bash -s -- -b /usr/local/bin
act --version
act version 0.2.89

A minimal workflow, actually run

# .github/workflows/ci.yml
name: CI
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Say hello
        run: echo "Hello from GitHub Actions"
act push -P ubuntu-latest=catthehacker/ubuntu:act-latest
[CI/build] ⭐ Run Set up job
[CI/build] 🚀  Start image=catthehacker/ubuntu:act-latest
[CI/build]   ✅  Success - Set up job
[CI/build] ⭐ Run Main actions/checkout@v4
[CI/build]   ✅  Success - Main actions/checkout@v4 [40.897398ms]
[CI/build] ⭐ Run Main Say hello
[CI/build]   | Hello from GitHub Actions
[CI/build]   ✅  Success - Main Say hello [164.605749ms]
[CI/build] ⭐ Run Complete job
[CI/build]   ✅  Success - Complete job
[CI/build] 🏁  Job succeeded

-P ubuntu-latest=catthehacker/ubuntu:act-latest maps the ubuntu-latest runner label to a Docker image that actually has the tools GitHub’s real runners ship with — without it, act falls back to a minimal image many real workflows won’t run cleanly on. Each ⭐ Run line is a real step actually executing, in order, inside the container.

Listing what a workflow would run, without running it

act --list -P ubuntu-latest=catthehacker/ubuntu:act-latest
Stage  Job ID  Job name  Workflow name  Workflow file  Events
0      build   build     CI             ci.yml         push

The bug: a failing step stops the job

# .github/workflows/broken.yml
name: Broken
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Fail on purpose
        run: exit 1
act push -W .github/workflows/broken.yml -P ubuntu-latest=catthehacker/ubuntu:act-latest
[Broken/build] ⭐ Run Main Fail on purpose
[Broken/build]   ❌  Failure - Main Fail on purpose [116.490278ms]
[Broken/build] exitcode '1': failure
[Broken/build] ⭐ Run Complete job
[Broken/build]   ✅  Success - Complete job
[Broken/build] 🏁  Job failed
Error: Job 'build' failed

exit 1 failed the step, and the job stopped there — no step after it ran. act itself exits non-zero too (this command’s shell would report a non-zero $?), which is exactly what lets a real CI system detect the failure and block a merge on it.

Practice

1. Add a step after the failing one and confirm it never runs.
      - name: Fail on purpose
        run: exit 1
      - name: This should not run
        run: echo "you should not see this"
[Broken/build] ⭐ Run Main Fail on purpose
[Broken/build]   ❌  Failure - Main Fail on purpose [113.923628ms]
[Broken/build] 🏁  Job failed
Error: Job 'build' failed

No ⭐ Run Main This should not run line anywhere in the output — confirms the step was skipped entirely, not run-and-ignored.

2. Force a step to run even after an earlier failure.
      - name: Fail on purpose
        run: exit 1
      - name: Always runs
        if: always()
        run: echo "cleanup happened"
[Broken/build] ⭐ Run Main Fail on purpose
[Broken/build]   ❌  Failure - Main Fail on purpose [108.024836ms]
[Broken/build] ⭐ Run Main Always runs
[Broken/build]   | cleanup happened
[Broken/build]   ✅  Success - Main Always runs [103.078221ms]
[Broken/build] 🏁  Job failed

if: always() runs regardless of prior failures — the job as a whole still reports failed (one step failed), but the cleanup step ran, which is exactly the pattern for a step that must execute no matter what (like uploading logs) even when earlier steps broke.

3. Check act's own exit code after a failing run.
act push -W .github/workflows/broken.yml -P ubuntu-latest=catthehacker/ubuntu:act-latest > /dev/null 2>&1
echo "exit code: $?"
exit code: 1

act propagates the workflow’s failure as its own process exit code — this is what a real CI system’s own automation checks, not the human-readable log output.

Next: secrets and how their values are handled in logs.

Frequently Asked Questions

What is act, and why use it instead of pushing to GitHub to test a workflow?
act runs a workflow YAML file against real Docker containers on your own machine, executing the same steps a GitHub-hosted runner would — no push, no GitHub account, no waiting on a real runner queue. It's not a simulation of GitHub Actions; it's the same job model running locally, which is why its output looks like a real Actions log.
What's the difference between a workflow, a job, and a step?
A workflow is the whole YAML file. A job is a group of steps that runs on one runner (a fresh container, in act's case); a workflow can have multiple jobs, which run in parallel by default unless a needs: dependency says otherwise. A step is one action (uses:) or one shell command (run:) inside a job — steps in the same job always run in order, one after another.
Why did the job stop after the failing step instead of running anything after it?
By default, a step that exits non-zero fails the job immediately and skips every remaining step — the same fail-fast behavior a shell script has when a command returns a non-zero exit code. A step needs an explicit if: always() (or similar) to run even after a prior step failed; nothing here does that, so the job stops.
Does uses: actions/checkout@v4 do something special under act?
No — it behaves like the real action, copying the repository's files into the job's working directory inside the container. act genuinely runs the action's code (pulling the actions/checkout image or running its bundled script), not a stub, which is why the same @v4 pin that works on GitHub also works here.
What does the exit code from `act` itself tell me?
act exits non-zero if any job in the run failed — checking $? after running act is the scriptable way to know whether a workflow run succeeded, the same as checking a build command's own exit code in any other CI setup.