Decoding GitHub Actions Shell Execution Environments and Container Behavior

The configuration of the shell environment within GitHub Actions workflows is a critical, yet frequently misunderstood, aspect of continuous integration and deployment pipelines. While the platform provides sensible defaults for standard runner environments, the behavior shifts significantly when containers are introduced, leading to unexpected execution contexts and subtle bugs that can derail complex automation tasks. Understanding the interplay between the runner operating system, the container image definition, and explicit shell overrides is essential for maintaining deterministic and robust workflows. The default shell behavior, the impact of Dockerfile configurations, and the advanced text processing capabilities available within the shell environment all contribute to the reliability of GitHub Actions executions.

Default Shell Behavior on Hosted Runners

In standard GitHub Actions workflows running on non-Windows platforms, such as ubuntu-latest, the default shell is Bash. This expectation holds true for jobs running directly on the runner without any containerization. When a step executes without an explicit shell key defined, the runner looks for Bash in the system path. If Bash is not found, the system falls back to sh. This fallback mechanism ensures that commands can still execute, even if the preferred shell is missing, though it may limit the availability of certain Bash-specific features like arrays or advanced string manipulation.

On Windows runners, the default behavior differs. PowerShell Core (pwsh) is the default shell, ensuring a consistent modern scripting environment across Windows environments. However, developers can override these defaults at the job or step level. For instance, specifying shell: bash on a Windows runner will utilize the Bash shell provided by Git for Windows. This flexibility allows teams to standardize their scripts across different operating systems or leverage specific shell features required by their toolchain.

The following table outlines the default and supported shells across different platforms:

Platform Default Shell Fallback / Alternative Description
Linux / macOS bash sh Bash is the default. Falls back to sh if Bash is not in the path.
Windows pwsh cmd, powershell PowerShell Core is the default. Legacy PowerShell and CMD are also supported.
All Platforms python N/A Executes the python command. Must be specified explicitly.
All Platforms bash N/A Default on non-Windows. Must be specified explicitly on Windows.

The Container Shell Anomaly

A significant deviation from expected behavior occurs when workflows utilize containers. When a job specifies a container using the container key, the default shell for the run steps changes from Bash to sh, regardless of the underlying runner's default. This behavior has been observed on both GitHub-hosted runners (such as ubuntu-latest) and self-hosted runners (such as Debian Linux 11).

This anomaly persists even when the Dockerfile explicitly defines Bash as the default shell. For example, a Dockerfile might include the instruction SHELL ["/bin/bash", "--login", "-c"] or set the CMD to /bin/bash. Despite these explicit definitions in the container image, GitHub Actions continues to execute the run steps using sh by default. This discrepancy requires developers to explicitly specify shell: bash for every step that relies on Bash-specific syntax, or to define the shell at the job level using defaults.run.shell.

The issue was documented in GitHub Actions Runner issue #1533, where users reported that commands ran in the sh shell instead of bash when using containers. The user's expectation was that the shell would default to Bash, mirroring the behavior of a non-containerized job. However, the runner's behavior overrides the container's shell configuration for the default execution context. To resolve this, users must either:
- Explicitly set shell: bash on each step.
- Use defaults.run.shell: bash at the job level.

This behavior highlights a crucial distinction: the container's default shell (as defined by CMD or SHELL) affects the interactive session if one were to attach to the container, but it does not influence the GitHub Actions runner's default choice for executing workflow steps. The runner maintains its own logic for determining the default shell, which currently defaults to sh for containerized jobs on Linux.

Advanced Shell Techniques for Workflow Automation

Beyond shell selection, the power of Bash within GitHub Actions allows for sophisticated data extraction and variable manipulation. A common task in automation is extracting specific identifiers from GitHub context variables. For example, extracting the Pull Request ID or parsing the repository owner and name from the GITHUB_REPOSITORY variable requires careful use of shell built-ins and text processing tools.

One effective technique involves using the IFS (Internal Field Separator) variable in Bash to split strings. The command IFS='/' read -r OWNER REPOSITORY <<< "$GITHUB_REPOSITORY" allows developers to cleanly split the repository string (e.g., owner/repo) into two separate variables, OWNER and REPOSITORY. This approach is robust and avoids the need for external tools for simple string parsing.

For more complex extraction tasks, such as isolating the branch name from a full reference like refs/heads/main, developers often pipe the output to awk. The command echo ${{ github.event.ref }} | awk -F'/' '{print $NF}' utilizes awk to split the string by the / character and print the last field ($NF). This method is particularly useful when the structure of the input is consistent but the number of segments may vary.

It is important to note that expressions like ${{ github.event.ref }} are evaluated by the GitHub Actions runner before the shell executes the command. Therefore, the shell receives the resolved value, not the literal string ${{ github.event.ref }}. This distinction is crucial for debugging and understanding the flow of data between the workflow definition and the execution environment.

Command substitution is another key feature when working with shell commands in GitHub Actions. The modern construct $(...) is preferred over the older backtick syntax `...` due to its clarity and ease of nesting. For example, assigning the result of a curl command to a variable using PR_ID=$(curl -s ...) allows the output to be captured and used in subsequent steps. This pattern is frequently used to query the GitHub GraphQL API for detailed repository information.

Explicit Shell Configuration and Customization

While defaults provide convenience, explicit configuration ensures predictability. Developers can override the default shell for individual steps or for an entire job. This is particularly useful when a workflow requires different shells for different tasks, such as using PowerShell for Windows-specific tasks and Bash for Linux-specific tasks, even on the same runner.

The shell key can be set to any of the supported shells, including bash, sh, pwsh, powershell, cmd, and python. Additionally, developers can define custom shell templates using the syntax command [...options] {0} [...more_options]. In this template, {0} is replaced by the path to the temporary script file created by GitHub Actions. For example, shell: perl {0} would execute the script using Perl, provided Perl is installed on the runner.

To streamline workflow definitions, the defaults.run key can be used at the job or workflow level to set a default shell for all run steps. This reduces redundancy and ensures consistency across the workflow. For instance, setting defaults: { run: { shell: bash } } ensures that all steps use Bash, regardless of the runner's default or container configuration.

The following table illustrates how to explicitly set the shell for various scripting languages:

Shell Example Step Notes
Bash shell: bash Default on Linux/macOS.
PowerShell Core shell: pwsh Default on Windows. Cross-platform.
PowerShell Desktop shell: powershell Windows only.
Command Prompt shell: cmd Windows only. Appends .cmd to scripts.
Python shell: python Executes Python scripts.
Custom shell: perl {0} Executes with Perl. Must be installed on runner.

Conclusion

The behavior of shells in GitHub Actions is nuanced, particularly when containers are involved. While Bash is the default for standard Linux runners, containerized jobs default to sh, overriding the Dockerfile's shell settings. This behavior necessitates explicit shell configuration to ensure that Bash-specific features are available. Developers must be aware of these defaults and actively manage shell selection to avoid unexpected failures. By leveraging explicit shell overrides, advanced text processing tools like awk, and robust variable parsing techniques, teams can build reliable and efficient CI/CD pipelines that are resilient to environment variations.

Sources

  1. GitHub Actions Runner Issue #1533
  2. Unpacking Bash shell tips from a GitHub Actions workflow
  3. GitHub Actions All The Shells

Related Posts