GitHub Actions Shells: Make Runner Semantics and Input Boundaries Explicit
Pin each Actions run-step shell, understand its failure flags and lifetime, and keep event data out of generated shell source.
A GitHub Actions run block is executed by a shell on the runner. The shell choice determines syntax, startup behavior, pipeline status, and how an exit code becomes a failed step. A multiline block is one process, but each run step is a separate process. Therefore, a cd, shell variable, trap, or set -o change does not automatically carry into the next step. Treat shell selection and step boundaries as part of the workflow’s interface, not as cosmetic YAML.
Security adds a second boundary: GitHub expressions can insert event data into workflow text. Pull request titles, bodies, branch names, labels, and other context values may be controlled by someone outside the trusted repository. If an expression is substituted directly into a shell script, the value can become shell syntax before quoting in the intended assignment protects it. The safe design passes the value through the environment or an argument boundary, then treats it as data inside a quoted shell expansion.
Pin the shell instead of inheriting a runner default
On Linux and macOS, an unspecified shell and an explicit shell: bash are not identical. GitHub documents the unspecified form as Bash with -e and a fallback to sh; explicit Bash uses –noprofile –norc -eo pipefail, also with a fallback on supported platforms. The first form’s pipeline status can be the status of only the last command, while pipefail makes a failed earlier pipeline stage visible. A workflow should select the behavior it requires rather than depending on an implicit default.
jobs:
verify:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: .
steps:
- uses: actions/checkout@v7
- name: Run checks
run: |
printf 'Bash=%s\n' "$BASH_VERSION"
./scripts/verify.sh
The uses step shown here is illustrative of workflow structure; the official actions/checkout repository currently documents the v7 major. A major-version tag is mutable, so teams with stricter supply-chain requirements can pin the action to a reviewed full commit SHA. Action pinning and permission scope are separate decisions. The run block starts a non-login shell by default. Do not expect .bashrc, aliases, interactive functions, or a workstation’s exported state to be present. Put reusable logic in a reviewed script and invoke it explicitly, or declare required environment values in the workflow/job/step env mapping.
Windows runners have different shells and argument conventions. GitHub documents pwsh as the default Windows shell and supports cmd, Windows PowerShell, and Bash from Git for Windows in specific configurations. Bash syntax, PowerShell error handling, and cmd.exe status propagation are not interchangeable. If the same workflow targets multiple operating systems, use separate shell-specific steps or a script file with a deliberately selected interpreter rather than placing mixed syntax behind one conditional.
Model process and environment lifetime
All commands on separate lines within one multiline run block execute in the same generated script and shell. A new run step gets a new shell process, so state established in the earlier step is not shared unless it is written through a supported workflow channel or persisted to the filesystem. This is a process-lifecycle boundary, not a YAML indentation quirk.
- name: Prepare workspace state
shell: bash
run: |
build_dir="$RUNNER_TEMP/project-build"
mkdir -p "$build_dir"
printf 'build_dir=%s\n' "$build_dir" >> "$GITHUB_ENV"
- name: Use workspace state
shell: bash
run: |
test -d "$build_dir"
./scripts/check-build-dir.sh "$build_dir"
GITHUB_ENV affects later steps; it is not a way to mutate the already-running shell in the same step. This example should also be reviewed for the input trust and path policy of the actual project. Environment files have line-oriented formats, so do not write untrusted multiline values into them without following GitHub’s documented delimiter protocol and validating the value. If a value is only needed within one block, keep it in a shell variable instead.
Set working-directory only to a path that exists when that step starts. The working directory is process configuration and is evaluated separately for each step. A path created earlier is available on the runner filesystem, but the next shell starts with its own configured directory. For reproducibility, print a safe project-relative working directory in diagnostics and fail early when the expected checkout or generated directory is absent.
Treat fail-fast flags as policy, not magic
GitHub’s built-in Bash setting applies -e and pipefail. These options improve default detection but do not make a script’s every failure path correct. errexit has conditional contexts and exceptions, and a pipeline’s aggregate status still does not explain which stage failed. When a step must distinguish a missing optional file from a failed required command, write an explicit if or capture the status intentionally.
set -u
if ./scripts/generate-index.sh; then
printf '%s\n' 'index generated'
else
status=$?
printf 'index generation failed with status %s\n' "$status" >&2
exit "$status"
fi
Avoid resetting the runner’s safeguards at the top of every script without a reason. If a script intentionally disables errexit to collect several independent results, record each status and return a deliberate aggregate result. A diagnostic command after the real work can accidentally become the last successful command and mask a failure if the script has no effective fail-fast behavior. Test a known failing command and confirm the step is reported as failed. When errors are intentionally tolerated, state the tolerated case in the YAML or shell and emit a clear diagnostic.
The shell process’s exit status is how the runner decides whether the step succeeded, unless the workflow config changes that behavior with options such as continue-on-error. The runner’s default shell wrapper contributes flags, but a script’s control flow still determines which final status it returns. A passing printf after an ignored failure must not conceal a failed build command. For critical pipelines, explicitly assert the expected output and status policy instead of assuming the shell’s flags prove semantic correctness.
Keep expressions out of shell source
This pattern is unsafe when an attacker can influence the title:
- name: Check title
run: |
title="${{ github.event.pull_request.title }}"
./scripts/check-title.sh "$title"
The workflow engine substitutes an expression into the script before Bash parses it. A quote or shell metacharacter in the title can therefore alter the generated command. Quoting the shell variable after that insertion does not protect the source-generation step.
Use an intermediate environment variable instead:
- name: Check title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
shell: bash
run: ./scripts/check-title.sh "$PR_TITLE"
Now the expression value is supplied as process data, and the script contains a fixed command. Quote the variable so spaces and wildcard characters remain within one argument. The called program must still validate the value according to its own rules; correct shell quoting prevents argument splitting and source injection, but it does not establish that a title is semantically acceptable.
Apply the same rule to branch names, commit messages, issue content, labels, and values received from pull requests. An environment variable is safer than direct source interpolation, but it is not a universal safety wrapper: programs may parse values as options, formats, paths, or regular expressions. Use – where the target command documents it, validate allowed values, and pass each value as its own quoted argument. Avoid constructing a larger command string and feeding it to eval, bash -c, or a second shell.
Design workflow scripts for the runner you actually use
Use a checked-in script for substantial logic. It can be syntax checked locally, reviewed with the same ShellCheck policy as production scripts, and exercised by a test suite. Keep the workflow step as a short adapter that chooses the interpreter and passes explicit values. This also makes the action’s temporary script generation less important to the code’s maintainability.
Remember that a custom shell template is itself an execution contract. GitHub uses the first whitespace-delimited word as the command and inserts the temporary script path at {0}. That does not automatically provide a shell-safe template for every path or platform. Prefer built-in shell names unless a custom interpreter is necessary, and test the exact template on the target runner image.
Workflows may run with repository secrets or write-capable tokens. A shell script triggered by an untrusted pull request should not execute untrusted repository code in a privileged context without a deliberate threat model. Separate untrusted validation from privileged release/deployment steps, scope permissions narrowly, and avoid exposing credentials to code that can be changed by the contributor being evaluated. Shell quoting cannot repair an authorization design that grants an attacker-controlled script a deployment token.
For every important step, test both the success and failure path on the same runner family used in CI. Include a pipeline where the first command fails and the last succeeds, a path with spaces, an unset variable, a non-ASCII event value, and a string containing quotes or shell metacharacters. Confirm that the workflow fails only when intended, does not execute injected text, and logs no secret data. The reliable workflow makes interpreter, process lifetime, environment, status handling, and trust boundary visible in the YAML and in the invoked script.
Related:
- Bash Pipeline Status: pipefail, PIPESTATUS, and Reliable Error Checks
- How to Keep a Shell Script ShellCheck-Clean from the Start
Sources: