Skip to content
Latchkey LogoLatchkey home

GitHub Actions pipefail is not on unless you ask for it

GitHub Actions pipefail is applied to a step only when that step, or a defaults block above it, says shell: bash. A step that says nothing runs under a different command with a different flag set, and in that command a command that fails on the left of a pipe cannot fail the step.

Each shell keyword beside the command the runner builds for it and whether a pipe can fail
The table on the left is reproduced in the body of this page, its first four rows quoted from the supported shells table in the GitHub docs. The panel on the right carries the argument formats verbatim from ScriptHandlerHelpers.cs.

What this error means

A step is green and the work it was supposed to do did not happen. The giveaway is a pipe: a build or a test run piped into tee so the output is kept, or into grep so it is filtered, or into a formatter. The exit status the runner sees is the status of the last command in that pipeline, and tee succeeds at writing a file whatever it was given. Nothing in the log announces this. You see the failing output from the left-hand command scroll past, then the step turns green, then the workflow carries on and publishes whatever the failing command left behind. The same step in the same repository behaves differently depending on one line you may never have written, which is why it tends to surface as a step that used to catch failures and now does not.

Actions log shape (illustrative, annotated)
##[group]Run ./build.sh | tee build.log
./build.sh | tee build.log
shell: /usr/bin/bash -e {0}
##[endgroup]
src/app.ts(14,3): error TS2551: Property 'lenght' does not exist on type 'Item[]'. Did you mean 'length'?
Found 1 error.
# the step exits 0: tee wrote the file, and tee is the last command in the pipeline

Two steps that differ by one line

These are written for this page and have never been run. They run the same pipeline. The first says nothing about a shell and the second says shell: bash, and that is the entire difference between a build failure that stops the workflow and a build failure that does not.

The counterintuitive part is that the first step is not running some unrelated shell. On a Linux runner it is running the bash binary. It is the arguments that differ, and the arguments are chosen by the keyword rather than by the binary.

.github/workflows/ci.yml (illustrative)
steps:
  - name: Build, failure not caught
    run: ./build.sh | tee build.log

  - name: Build, failure caught
    shell: bash
    run: ./build.sh | tee build.log

Common causes

The step never said which shell it wanted

The default on Linux and macOS runners is the sh argument format, and that format has no pipefail in it. Most steps in most workflows are written this way, so the majority of pipelines in the majority of repositories do not fail on an upstream error.

A shell template string was added and took the flags with it

Writing a shell value with a space in it replaces the entire argument format rather than adding to it. Somebody adds a template string to get a login shell, or to point at a specific interpreter, and the error flag and pipefail leave with the defaults.

The pipeline ends in something that always succeeds

tee, cat, a formatter, a filter that prints nothing. These are the ones that hide a failure rather than merely allowing it, because they exit zero on an empty or broken input. A pipeline ending in grep behaves differently again, since grep exits non-zero when it matches nothing.

The script sets its own options and turns the gate off

A step whose first line sets shell options deliberately, in order to capture an exit code and branch on it, is doing the right thing for that step. In our experience the problem arrives when that pattern gets copied into a step that does not capture the code and just carries on.

How to fix it

Name the shell on the step

One word turns on both the error flag and pipefail, with no change to your script. This is the smallest correct fix and the one to reach for first.

.github/workflows/ci.yml (illustrative)
  - name: Build
    shell: bash
    run: ./build.sh | tee build.log

Set it once for the whole workflow

A defaults block at workflow level applies to every run step below it, so you do not have to remember the keyword on each one. A job-level block overrides a workflow-level one, which the documentation states as a rule: "a default setting defined in a job will override a default setting that has the same name defined in a workflow".

.github/workflows/ci.yml (illustrative)
name: ci
on: push

defaults:
  run:
    shell: bash

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - run: ./build.sh | tee build.log

Set the option in the script when you cannot name the shell

Inside a container step, or anywhere the shell value is already a template string you do not control, put the option in the script itself. It costs one line and it is visible to anyone reading the step. It has to be its own line, which is the version of this fix that only looks like one: set takes everything after its options as positional parameters, so a single-line run: set -o pipefail ./build.sh | tee build.log turns the option on, assigns your script to $1, never runs it, and leaves the step green for a second reason.

.github/workflows/ci.yml (illustrative)
  - name: Build
    run: |
      set -o pipefail
      ./build.sh | tee build.log

Check the status yourself when the pipeline has to keep going

  1. Capture the array of exit statuses immediately after the pipeline, before anything else runs.
  2. Test the entry for the command you care about rather than the status of the pipeline.
  3. Exit with that status so the step reports what the command reported.
.github/workflows/ci.yml (illustrative)
  - name: Build and keep the log either way
    shell: bash
    run: |
      set +e
      ./build.sh | tee build.log
      status=${PIPESTATUS[0]}
      ./upload-log.sh build.log
      exit "$status"

The runner builds a different command for each keyword

The argument strings are a dictionary in ScriptHandlerHelpers.cs in actions/runner, with one entry per built-in keyword and a placeholder where the generated script path goes. Two of the entries are the ones that matter here, and only one of them mentions pipefail.

When a step names no shell, the Linux branch of ScriptHandler.cs sets the keyword to sh and then looks for the bash binary first, falling back to sh only when bash is absent. The binary is bash; the argument format is the one filed under sh. That is the whole mechanism, and it is why the docs describe the unspecified default as running "a different command to when bash is specified explicitly".

You do not have to work this out from the outside. The runner prints the command it resolved into the collapsed group at the top of every run step, and it prints the argument format before the script path has been substituted into it, which is why the line ends in a brace pair rather than a path. A step showing shell: /usr/bin/bash -e {0} has no pipefail. A step showing shell: /usr/bin/bash --noprofile --norc -e -o pipefail {0} has it. That line is the fastest check there is, and it is in every log you already have.

actions/runner, src/Runner.Worker/Handlers
// ScriptHandlerHelpers.cs, the built-in argument formats
["bash"] = "--noprofile --norc -e -o pipefail {0}",
["sh"] = "-e {0}",

// ScriptHandler.cs, the Linux branch when no shell was given
shellCommand = "sh";
commandPath = WhichUtil.Which("bash", false, Trace, prependPath) ?? WhichUtil.Which("sh", true, Trace, prependPath);

// ScriptHandler.cs, what the collapsed group at the top of the step prints
ExecutionContext.Output($"shell: {shellCommandPath} {argFormat}");

What each shell value actually runs

The published table and the source agree on the behavior and differ on one cosmetic detail, which is worth stating rather than smoothing over. The docs write the bash form as one combined flag; the runner source writes the same two options separately. Bash treats them identically, and the table below gives the docs spelling because that is the one you can look up.

The documentation is explicit about the split as well: "By default, fail-fast behavior is enforced using set -e for both sh and bash. When shell: bash is specified, -o pipefail is also applied to enforce early exit from pipelines that generate a non-zero exit status."

shell valueCommand run internallyCan a failing pipe fail the step?
unspecified, Linux or macOSbash -e {0}No
bashbash --noprofile --norc -eo pipefail {0}Yes
shsh -e {0}No
pythonpython {0}Not applicable
a template string such as bash {0}exactly what you wroteOnly if you asked for it

A custom shell string replaces the defaults entirely

The other half of the surprise is what happens when you do write a shell string. The documentation describes the format: "You can set the shell value to a template string using command [options] {0} [more_options]. GitHub interprets the first whitespace-delimited word of the string as the command, and inserts the file name for the temporary script at {0}."

The runner splits the value on the first space, uses the left side as the command and the right side verbatim as the arguments. Nothing is merged in. So shell: bash {0} gives you a bash with no options at all, which the documentation offers as a feature: "You can take full control over shell parameters by providing a template string to the shell options."

That is the case where a step loses both the error flag and pipefail at once, and it is how a step that was catching failures stops catching them after somebody adds a shell string for an unrelated reason.

actions/runner, src/Runner.Worker/Handlers
// ScriptHandlerHelpers.cs
var shellStringParts = shellOption.Split(" ", 2);

// ScriptHandler.cs, when the value was a template string
argFormat = $"{parsed.shellArgs}".TrimStart();

Why there is no recorded run on this page

The step in question passes, so there is no failure for this library to reproduce and no log worth publishing: a green step that should have been red looks exactly like a green step. Nothing here is transient either, so there is nothing for a runner to repair. The log fragment above is annotated and illustrative, and every claim about which command runs is traced to the dictionary entry that builds it.

How to prevent it

  • Put a defaults run shell block at the top of every workflow, so no step depends on the unspecified default.
  • Treat any shell value containing a space as a full override, and re-add the options you still want.
  • Review pipelines ending in tee, cat or a formatter first, since those are the ones that swallow a status.
  • When a step has to survive a failure, capture the pipeline statuses rather than removing the option.

Frequently asked questions

Is pipefail on by default in GitHub Actions?
Not for a step that names no shell. On Linux and macOS the default argument format is the one filed under sh, which sets the error flag and nothing else, even though the binary the runner launches is bash. Writing shell: bash on the step is what selects the format that includes pipefail.
Why did adding a shell template string stop my step from failing?
Because a template string replaces the whole argument format instead of adding to it. The runner splits your value on the first space and passes the remainder through untouched, so bash {0} runs bash with no options at all, without the error flag and without pipefail.
Does set -e catch a failing command inside a pipe?
No. The error flag acts on the status of the pipeline, and the status of a pipeline is the status of its last command unless pipefail changes that. A failing command on the left of a pipe is invisible to the error flag on its own, which is why the two options are usually set together.
What is the difference between shell bash and the default on Linux?
The command the runner builds. The documentation notes the unspecified default "runs a different command to when bash is specified explicitly", and lists them separately: one plain invocation with the error flag, and one with no profile, no rc file, the error flag and pipefail.
How do I tell which shell a step actually used?
Read the line beginning shell: in the collapsed group at the top of the step. The runner composes it from the binary it resolved and the argument format it selected, so it accounts for every default that applied, including one inherited from the job or the workflow. It settles the question without reading any YAML.

Related guides

References

A green step that shipped a broken build is the expensive kind. Latchkey keeps red meaning your code. Start free → 30-day trial · No credit card