Shell Commands and Error Handling in Workflows

A GitHub Actions job is a sequence of shell commands wired together by YAML. Each run: step launches a fresh shell process, executes a command, and reports an exit code back to the runner. If you do not understand how that exit code decides the fate of the rest of the job, you will eventually ship a broken deploy because a step you thought failed actually reported success, or watch a healthy pipeline stop dead because of a pipe you did not know was strict. This lesson goes past writing a basic run: step and into deliberate error handling: choosing shells on purpose, controlling what counts as failure, cleaning up after failures, and avoiding the shell command mistakes that quietly break security or hide real problems.

Overview / How It Works

Every run: step is dispatched to a shell chosen by the shell: key at the job or step level, or, if unset, the runner operating system default: bash on Linux and macOS runners, PowerShell (pwsh) on Windows runners. GitHub Actions does not execute your command line directly. It writes the run block to a temporary script file and invokes the shell against that file with specific flags, and those flags are what determine error handling.

On non-Windows runners, the default bash invocation is effectively bash --noprofile --norc -eo pipefail {0}. Errexit (-e) means the script stops immediately when any command exits non-zero. Pipefail (-o pipefail) means a pipeline’s exit status is the right-most command that actually failed, not just the last command in the pipe, so curl piped into grep correctly fails the step if curl fails even when grep would otherwise exit zero. One nuance worth remembering: nounset (-u) is never enabled by default, so a misspelled variable silently expands to an empty string unless you add that protection yourself. The sh shell only gets -e, not pipefail, so switching shell: sh silently removes that pipe safety net.

When a step exits non-zero, GitHub Actions marks that step failure, skips the remaining steps in the job by default, and marks the job and the overall workflow run failure. That failure conclusion is exactly what branch protection reads as a failing required check. You override the default skip behavior with the if: conditional functions success(), failure(), cancelled(), and always(), which GitHub Actions evaluates regardless of prior step outcomes. That is how you build cleanup, log-upload, and notification steps that run even after the pipeline has already blown up.

Syntax / Workflow Structure

shell: Command GitHub Actions runs Default error behavior
bash (default on Linux/macOS) bash --noprofile --norc -eo pipefail {0} errexit + pipefail
pwsh (default on Windows) pwsh -command ". '{0}'" stops on terminating errors only
sh sh -e {0} errexit only, no pipefail
python python {0} Python’s own exceptions, no shell semantics
cmd (Windows only) %ComSpec% /D /E:ON /V:OFF /S /C "CALL '{0}'" no errexit or pipefail

A few keys you will use constantly:

  • shell: sets the interpreter for one step, or under defaults: run: shell: at the job level, for every step in that job.
  • working-directory: sets the directory a single step runs in, without needing a separate cd command.
  • continue-on-error: converts a step failure into a non-blocking warning; the job can still conclude success even though that step failed.
  • if: combined with success(), failure(), cancelled(), or always() controls whether a step runs based on everything before it, bypassing the default skip-on-failure behavior.
  • steps.<id>.outcome is the raw pass/fail before continue-on-error is applied; steps.<id>.conclusion is the value after it.
  • env: sets environment variables safely, without textually rewriting the script.

Examples

Example 1: Default fail-fast behavior

name: CI

on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

      - name: Run tests
        run: npm test

      - name: Deploy preview
        run: ./scripts/deploy-preview.sh

If npm run lint exits non-zero, the job stops at that step immediately. npm test and the deploy script never run. GitHub Actions shows checkout and install as success, lint as a failure, and the two steps after it as skipped. The job and workflow run are both marked failed, and any branch protection rule watching this job blocks the merge.

Example 2: Shell selection changes pipefail behavior

jobs:
  pipefail-demo:
    runs-on: ubuntu-latest
    steps:
      - name: Pipeline failure under sh (no pipefail)
        shell: sh
        run: |
          false | echo "this always prints"
          echo "sh step reached the end, the pipe was treated as success"

      - name: Pipeline failure under bash (pipefail is default)
        shell: bash
        run: |
          false | echo "this always prints"
          echo "this line never runs"

Both steps run the identical pipeline, false | echo "...". Under shell: sh, only errexit is active, and errexit inspects the exit status of the last command in a pipe, which is echo, and echo always exits zero, so the sh step is reported as success and both lines print. Under shell: bash, pipefail is on by default, so the pipeline’s exit status is the failure from false; errexit stops the script right after that line, the second echo never runs, and the step is reported as failure. Same command text, opposite outcome, purely because of the shell: key.

Example 3: Scoping continue-on-error and re-raising a masked failure

jobs:
  test-and-notify:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: Run flaky integration test
        id: integration
        continue-on-error: true
        run: ./scripts/run-integration-tests.sh

      - name: Fail the job if the flaky test really failed
        if: steps.integration.outcome == 'failure'
        run: |
          echo "::error::Integration tests failed after retries"
          exit 1

      - name: Notify on any failure
        if: failure()
        run: |
          echo "::warning::Pipeline failed - check the Actions tab for logs"

continue-on-error on the integration test step means that step shows a warning even if it fails, and by itself it would let the job finish as success. The next step reads steps.integration.outcome, which reports the raw failure regardless of continue-on-error, and deliberately exits 1 to fail the job for real. The final step runs because if: failure() re-evaluates after that new failure, so a notification always reaches the Actions tab even though the original flaky step never shows red.

Step by Step

Building Example 3 methodically:

  1. Define the job and add actions/checkout so the workflow’s scripts are present.
  2. Add the integration test step with an id so later steps can reference its result, and mark only that one step continue-on-error: true – never the whole job.
  3. Immediately add a follow-up step gated on steps.integration.outcome == 'failure' that prints an error annotation and exits 1, turning the masked warning back into a real, blocking failure.
  4. Add a final step gated on if: failure() so it runs only when the job has actually failed, and use it for notification or artifact upload rather than duplicating test logic.
  5. Set explicit minimal permissions – contents: read is enough here, since nothing in the job writes to the repository, issues, or packages.

Common Mistakes

Mistake 1: Assuming shell state persists across steps

- name: Enter app directory
  run: cd app

- name: Install and build
  run: |
    npm ci
    npm run build

Each run: step is its own shell process that exits the moment the step ends, so the working directory change from cd app is thrown away, and the second step starts back at the workflow’s default directory. The build step either fails outright with no package.json found, or silently builds the wrong project if the repository root happens to have its own package.json.

- name: Install and build
  working-directory: app
  run: |
    npm ci
    npm run build

The fix uses working-directory: on the step that needs it, or combines both commands into one run: block so the directory change and the build happen in the same shell process. For values, not just directories, that need to survive into later steps, append to the GITHUB_ENV or GITHUB_PATH files instead of using export, since export also dies with the shell that created it.

Mistake 2: Interpolating an untrusted expression directly into a run line

- name: Greet PR author
  run: echo "Thanks for the PR, ${{ github.event.pull_request.title }}!"

GitHub Actions performs the ${{ }} substitution as plain text before the shell ever parses the line, and a pull request title is fully attacker-controlled, including from a fork. A title crafted with a quote, a shell command, and a comment character becomes literal shell syntax that executes with whatever permissions and secrets the job has available – a real script-injection vulnerability that has affected public repositories in the past.

- name: Greet PR author
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: echo "Thanks for the PR, $PR_TITLE!"

The fix passes the same expression through an env: key instead, so GitHub Actions sets it as an environment variable through the process environment rather than rewriting the script text, and the shell treats the value as inert data. The same rule applies to commit messages, branch names, and any other field that ultimately comes from someone else’s pull request or issue.

Best Practices

  • Set shell: explicitly wherever pipefail or errexit semantics matter, rather than relying on memory of what the runner’s operating system defaults to.
  • Add set -euo pipefail as the first line of any nontrivial multi-line bash block; bash already runs with errexit and pipefail by default in GitHub Actions, but writing it out documents intent, adds nounset protection, and keeps the script portable if it is ever copied outside Actions.
  • Never interpolate an expression from an untrusted source directly into a run: line; pass it through env: and reference it as a shell variable instead.
  • Scope continue-on-error to one specific step and always pair it with a follow-up step that checks that step’s outcome, so a masked failure cannot silently pass a required status check.
  • Use if: failure() and if: always() deliberately for cleanup, log upload, and notification steps rather than assuming the default skip-on-failure behavior covers those cases.
  • Use the ::error:: and ::warning:: workflow commands, or the step summary file, to surface actionable failure detail in the pull request and Actions UI instead of leaving it buried in raw logs.
  • Avoid a blanket || true after a command; if a specific failure is genuinely expected, check for that specific exit code rather than silencing every possible error the command could produce.
  • Quote shell variables rather than leaving them bare, especially values that originated from a branch name, commit message, or other external input, to avoid word splitting and globbing surprises.

Practice Exercises

  1. Take a two-step job where the first step runs cd frontend and the second step runs npm test, and rewrite it so the tests reliably run inside the frontend directory using working-directory: instead of a separate cd step.
  2. Write a job step that greets a contributor using the head commit message from a push event, without ever letting that commit message be interpolated directly into the run: line, and note why the env: approach is required.
  3. Add a step that calls a script expected to be occasionally flaky, mark only that step continue-on-error: true, then add the two follow-up steps needed to both re-raise a real failure using the step’s outcome and report the final result with a workflow error or warning command.

Summary

Shell error handling in GitHub Actions comes down to a small set of defaults you have to know cold: bash steps run with errexit and pipefail already on, sh steps only get errexit, and nounset is never on unless you add it. A failed step skips everything after it and fails the job unless you deliberately opt back in with success(), failure(), cancelled(), or always(). continue-on-error hides a failure from the job conclusion, so treat it as a step-scoped exception that always needs a follow-up gate, never a blanket safety net. And no matter how convenient it looks, an untrusted expression belongs in an env: variable, never typed directly into a run: line, because GitHub Actions substitutes expressions as text before the shell ever sees them.