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.

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.
##[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 pipelineTwo 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.
steps:
- name: Build, failure not caught
run: ./build.sh | tee build.log
- name: Build, failure caught
shell: bash
run: ./build.sh | tee build.logCommon 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.
- name: Build
shell: bash
run: ./build.sh | tee build.logSet 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".
name: ci
on: push
defaults:
run:
shell: bash
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: ./build.sh | tee build.logSet 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.
- name: Build
run: |
set -o pipefail
./build.sh | tee build.logCheck the status yourself when the pipeline has to keep going
- Capture the array of exit statuses immediately after the pipeline, before anything else runs.
- Test the entry for the command you care about rather than the status of the pipeline.
- Exit with that status so the step reports what the command reported.
- 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.
// 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 value | Command run internally | Can a failing pipe fail the step? |
|---|---|---|
| unspecified, Linux or macOS | bash -e {0} | No |
| bash | bash --noprofile --norc -eo pipefail {0} | Yes |
| sh | sh -e {0} | No |
| python | python {0} | Not applicable |
| a template string such as bash {0} | exactly what you wrote | Only 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.
// 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?
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?
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?
What is the difference between shell bash and the default on Linux?
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?
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
- GitHub Actions: workflow syntax, jobs.<job_id>.steps[*].shell and the supported shells table
- actions/runner: ScriptHandlerHelpers.cs, the built-in shell argument formats
- actions/runner: ScriptHandler.cs, how a shell keyword or template string is resolved
- GitHub Actions: workflow syntax, defaults.run.shell
- GNU Bash manual: the set builtin, pipefail and the positional parameters
- GitHub Actions documentation