GitHub Actions serves as the backbone for modern continuous integration and continuous deployment (CI/CD) pipelines, offering a robust framework for automating software development workflows. At the heart of this automation lies the execution of shell scripts within workflow jobs. Whether compiling code, executing tests, or launching applications, the ability to run Bash scripts and other shell commands provides developers with the flexibility to customize actions to exact specifications. Understanding the underlying mechanics of how GitHub Actions handles shell execution, including default shell assignments, runner environments, and environment variable management, is critical for building reliable and efficient workflows.
Runner Environments and Process Isolation
The foundation of any GitHub Actions workflow is the runner. A runner is a server that has the GitHub Actions runner application installed, functioning similarly to hosted agents in other CI/CD platforms like Azure DevOps. Runners can be hosted by GitHub or self-hosted by the user. When a workflow is triggered, the runner listens for available jobs, executes one job at a time, and reports progress, logs, and results back to GitHub.
GitHub-hosted runners are based on three primary operating systems: Ubuntu Linux, Microsoft Windows, and macOS. Each job in a workflow runs in a fresh virtual environment, ensuring isolation between builds. Users can inspect the software installed on each of these GitHub-hosted runner VM images to understand the available tools. If a specific operating system or hardware configuration is required, users can host their own runners using self-hosted runners.
Within a workflow job, steps can be either pre-built actions or custom shell commands initiated using the run keyword. Each run keyword represents a new process and a new shell in the runner environment. When multi-line commands are provided within a single run block, each line executes within the same shell instance, allowing for state persistence across lines such as variable definitions.
Default Shell Assignments by Platform
The shell language used to execute commands depends heavily on the runner environment specified in the workflow. Each platform has a default shell language that GitHub Actions utilizes automatically if no specific shell is declared. Understanding these defaults is essential for writing cross-platform compatible workflows.
| Platform | Default Shell | Description |
|---|---|---|
| Windows | pwsh |
This is the default shell used on Windows, specifically PowerShell Core. GitHub appends the .ps1 extension to script names. If a self-hosted Windows runner does not have PowerShell Core installed, the system falls back to PowerShell Desktop. |
| Non-Windows | bash |
The default shell on non-Windows platforms (Linux/macOS) is Bash, with a fallback to sh. When specifying a bash shell on Windows, the Bash shell included with Git for Windows is used. |
For example, a workflow job running on windows-latest will execute PowerShell Core commands by default. A basic workflow step might look like the following, where write-output is a PowerShell command:
yaml
jobs:
name-of-job:
runs-on: windows-latest
steps:
- name: Hello world
run: |
write-output "Hello World"
In this scenario, the run keyword triggers the default pwsh shell because the runner is Windows-based. If the same run block were executed on ubuntu-latest, it would use bash, and the PowerShell command would fail unless explicitly changed.
Customizing Shell Execution and Defaults
While default shells provide a baseline, workflows often require specific shell environments for compatibility or syntax reasons. Developers can override the default shell for individual steps or set default settings for an entire job. When more than one default setting is defined with the same name, GitHub uses the most specific default setting. For instance, a default setting defined at the job level will override a default setting defined at the workflow level.
To set the default shell for an entire job, the defaults key is used within the job configuration:
yaml
name: my workflow
on: push
jobs:
name-of-job:
runs-on: windows-latest
defaults:
run:
shell: pwsh
steps:
- name: Hello world
run: |
write-output "Hello World"
This configuration ensures that all run steps in the name-of-job job use PowerShell Core (pwsh), regardless of the individual step configuration. This is particularly useful for Windows workflows where PowerShell is the native scripting environment.
Bash Scripting and Workflow Automation
Bash scripts are widely used in GitHub Actions, especially on Linux-based runners, to outline the appropriate course of action for various tasks. They function as collections of scripting commands that can compile code, execute tests, or launch applications. This flexibility allows developers to customize actions to meet specific requirements, simplifying complex development processes.
To run a Bash script in GitHub Actions, the script must first be committed to the repository. The following is a step-by-step approach to implementing a Bash script workflow:
- Create a repository on GitHub.
- Create the action file under the
.github/workflowsdirectory, typically namedblank.ymlor similar. - Store the Bash script (e.g.,
bash.sh) in the repository root or a designated directory.
The workflow file defines the trigger and the execution steps. For example, a workflow triggered manually via workflow_dispatch might look like this:
yaml
name: Bash Script
on:
workflow_dispatch:
jobs:
bash-script:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Run Bash script
run: bash bash.sh
The on: workflow_dispatch key specifies that the workflow should start manually. The uses: actions/checkout@v2 step ensures the repository code is available on the runner. The run: bash bash.sh step explicitly invokes the Bash shell to execute the script. This method provides clear identification and separation of concerns, making the workflow easier to maintain and debug.
Advanced Shell Techniques and Environment Management
Effective workflow design often requires more than simple script execution. It involves manipulating environment variables, parsing complex data structures, and managing secrets securely.
Parsing Variables and Data Extraction
Bash provides powerful tools for data manipulation within workflows. For instance, extracting the owner and repository from the GITHUB_REPOSITORY environment variable can be achieved using the Internal Field Separator (IFS):
bash
IFS='/' read -r OWNER REPOSITORY <<< "$GITHUB_REPOSITORY"
This command splits the GITHUB_REPOSITORY value (typically formatted as owner/repository) into two separate variables, OWNER and REPOSITORY. This technique is useful for constructing API calls or logging context.
Similarly, complex data extraction can be performed using awk and curl. For example, retrieving a pull request ID might involve a GraphQL API call:
bash
HEADREFNAME=$(echo ${{ github.event.ref }} | awk -F'/' '{print $NF}')
PR_ID=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
-X POST \
-d '{"query": ... }' \
"$GITHUB_GRAPHQL_URL" \
| jq '.data.repository.pullRequests.nodes[].number' \
)
This snippet demonstrates how to combine shell utilities (awk, curl, jq) to extract specific data points from GitHub's API. Note that this example uses shell: bash to ensure the correct execution environment.
Managing Environment Variables and Secrets
GitHub Actions provides mechanisms to pass data between steps and jobs. Environment variables can be made available to subsequent steps by writing to the $GITHUB_ENV file. The step that creates the variable does not have access to the new value, but all subsequent steps in the job will.
Using Bash (Linux/macOS):
bash echo "{environment_variable_name}={value}" >> "$GITHUB_ENV"Using PowerShell Core (v6+):
powershell "{environment_variable_name}={value}" >> $env:GITHUB_ENVUsing PowerShell Desktop (v5.1 and below):
powershell "{environment_variable_name}={value}" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
It is good practice to treat environment variables as case-sensitive, irrespective of the operating system's native behavior. Additionally, default environment variables named GITHUB_* and RUNNER_* cannot be overwritten, with the exception of the CI variable, though this behavior is not guaranteed to persist.
For passing secrets between steps within the same job, the GITHUB_OUTPUT environment file is used. Secrets should be masked to prevent leakage in logs:
yaml
on: push
jobs:
generate-a-secret-output:
runs-on: ubuntu-latest
steps:
- id: sets-a-secret
name: Generate, mask, and output a secret
run: |
the_secret=$((RANDOM))
echo "::add-mask::$the_secret"
echo "secret-number=$the_secret" >> "$GITHUB_OUTPUT"
- name: Use that secret output (protected by a mask)
run: |
echo "the secret number is ${{ steps.sets-a-secret.outputs.secret-number }}"
In PowerShell, the equivalent logic uses Set-Variable and Write-Output:
yaml
- id: sets-a-secret
name: Generate, mask, and output a secret
shell: pwsh
run: |
Set-Variable -Name TheSecret -Value (Get-Random)
Write-Output "::add-mask::$TheSecret"
"secret-number=$TheSecret" >> $env:GITHUB_OUTPUT
If a secret needs to be passed between jobs or workflows, it cannot be passed via GITHUB_OUTPUT. Instead, a secret store such as Vault should be used. The workflow generates a key for reading and writing to the store, stores the key as a repository secret (e.g., SECRET_STORE_CREDENTIALS), and retrieves the value in subsequent jobs.
Conclusion
Mastering shell execution in GitHub Actions requires a nuanced understanding of runner environments, default shell behaviors, and environment variable management. By leveraging the flexibility of Bash scripts and PowerShell, developers can create highly customized, efficient, and secure CI/CD pipelines. Whether parsing complex data structures with awk and curl or managing secrets through GITHUB_ENV and GITHUB_OUTPUT, the ability to control the shell environment is a critical skill for modern DevOps engineers. As workflows become more complex, adhering to best practices—such as explicit shell declarations and case-sensitive variable handling—ensures reliability and maintainability across diverse platform configurations.