Dynamic Environment Variable Management in GitHub Actions and Local Shell Scripts

Developing and debugging shell scripts locally, then deploying the same code to a GitHub Actions (GHA) Continuous Integration and Continuous Deployment (CI/CD) pipeline, often reveals a critical divergence in environment variable handling. In a local terminal, exporting a variable makes it available to subsequent commands within that session. In GitHub Actions, however, each step runs in its own isolated process context. A variable exported in one step is not automatically visible to subsequent steps, even within the same job. This architectural difference forces developers to write duplicated logic: one version for local development using standard shell exports, and another for GitHub Actions using the $GITHUB_ENV file. This article examines a robust strategy to unify these two environments, allowing a single script to manage environment variables seamlessly across both local development and GitHub Actions workflows.

The Isolation Problem in GitHub Actions Steps

GitHub Actions executes workflows as a sequence of jobs, which in turn consist of individual steps. Each step runs in a runner environment, typically Linux or Windows. While this model provides isolation and cleanliness, it creates a specific challenge for state persistence between steps.

In a standard bash shell, the command export MY_VAR="myValue" sets an environment variable that remains accessible to all child processes and subsequent commands within that shell session. This works perfectly within a single GitHub Actions step. If a shell script sets a variable using export, that variable is available to other commands executed within that same script and, by extension, within the context of that specific GHA job step.

However, the scope of an exported variable does not extend beyond the current step. When a GitHub Actions workflow moves from one step to the next, the environment is reset. Variables set via export in Step A are not present in Step B. This is a fundamental design choice in GHA to ensure reproducibility and security. To pass data from one step to another, developers must use specific mechanisms provided by the platform. The primary mechanism for this is the $GITHUB_ENV file. Writing a key-value pair to this file makes the variable available to all subsequent steps in the job.

The challenge arises when developers wish to maintain the "Don't Repeat Yourself" (DRY) principle. Writing separate logic for local testing (using export) and production CI/CD (using $GITHUB_ENV) leads to code duplication and increased maintenance overhead. A unified approach requires the script to detect its execution environment and adapt its behavior accordingly.

Detecting the GitHub Actions Environment

To implement a unified variable-setting strategy, the script must first determine whether it is running locally or within a GitHub Actions runner. GitHub Actions sets several default environment variables in its runner environments that are not present in a standard local shell. These variables serve as reliable indicators of the execution context.

One of the most straightforward indicators is the GITHUB_ACTIONS environment variable. This variable is present when the script is running within a GitHub Actions workflow. It is absent in a local development environment. By checking for the existence of GITHUB_ACTIONS, a script can branch its logic to handle variable persistence correctly.

The detection logic is simple: if GITHUB_ACTIONS is set (and not empty), the script is in a GitHub Actions environment. If it is not set, the script is running locally. This binary check allows for a single conditional block that handles the dual requirements of local export and GHA file writing.

The Unified Variable Setting Strategy

The goal is to write a function or script snippet that sets an environment variable in a way that works for immediate use (local shell or current GHA step) and for future use (subsequent GHA steps). This requires two actions when in GitHub Actions:
1. Write the variable to $GITHUB_ENV so it persists to later steps.
2. Export the variable to the current shell environment so it is available immediately within the current step.

When running locally, only the export is necessary, as the concept of $GITHUB_ENV does not apply and attempting to write to it would likely cause errors or write to a non-existent path.

Implementation Logic

The logic flow is as follows:
- Always export the variable to the current shell environment. This ensures immediate availability in both local shells and the current GHA step.
- Check if the GITHUB_ACTIONS environment variable is set.
- If GITHUB_ACTIONS is set, append the variable name and value to the $GITHUB_ENV file.

This approach ensures that the variable is available immediately (via export) and persistently across steps (via $GITHUB_ENV) when in GitHub Actions. In a local environment, the check fails, and only the export command executes, keeping the script clean and error-free.

Creating a Reusable Function

To avoid repeating the conditional logic for every single variable, it is efficient to encapsulate this behavior in a bash function. This function accepts two arguments: the variable name and the variable value.

```bash
setEnvVar() {

Set an env var's value at runtime with dynamic variable name

If in GitHub Actions runner, will export env var both to Actions and local shell

Usage:

setEnvVar "variableName" "variableValue"

varName=$1
varValue=$2
if [ ! -z $GITHUB_ACTIONS ]
then

We are in GitHub CI environment - export to GitHub Actions workflow context for availability in later steps in this workflow

cmd=$(echo -e "echo \x22""$varName""=""$varValue""\x22 \x3E\x3E \x24GITHUB_ENV")
eval $cmd
fi

Export for local/immediate use, whether on GHA runner or shell/wherever

cmd="export ""$varName""=\"""$varValue""\""
eval $cmd
}
```

This function, setEnvVar, abstracts the complexity of environment detection and file writing. It takes the variable name as a string ($1) and the variable value as a string ($2).

Argument Handling

It is crucial to pass the variable name as a literal string (e.g., setEnvVar "MY_VAR" "myValue") rather than as an evaluated variable (e.g., setEnvVar $MY_VAR "myValue"). If the latter is used, the shell evaluates $MY_VAR before passing it to the function. Since the variable is being initialized, it is likely empty or undefined, resulting in an empty string being passed as the variable name. This would cause the function to fail to set the correct variable. By passing the name in quotes, the function receives the exact string it needs to write to $GITHUB_ENV or export.

The Use of eval

The function uses eval to construct and execute the commands dynamically. This is necessary because the variable name is not known at the time of function definition; it is provided at runtime. The eval command allows the shell to interpret the constructed string as a command. For example, eval "export MY_VAR=myValue" executes the export command for the specific variable named MY_VAR. Similarly, the echo command for $GITHUB_ENV is constructed as a string and executed via eval to ensure the correct variable name and value are written to the file.

Writing to GITHUB_ENV in GitHub Actions

The $GITHUB_ENV file is a special file in GitHub Actions that allows scripts to set environment variables for subsequent steps. The path to this file is provided by the GITHUB_ENV environment variable itself.

When writing to this file, the format must be strictly KEY=VALUE. Each key-value pair must be on a new line. Multiple commands can be written to the same file, separated by newlines. It is essential to use UTF-8 encoding when writing to these files to ensure proper processing of the commands.

yaml name: Example Workflow for Environment Files on: push jobs: set_and_use_env_vars: runs-on: ubuntu-latest steps: - name: Set environment variable run: echo "MY_ENV_VAR=myValue" >> $GITHUB_ENV - name: Use environment variable run: | echo "The value of MY_ENV_VAR is $MY_ENV_VAR"

In this example, the first step writes MY_ENV_VAR=myValue to the $GITHUB_ENV file. The second step, running in a new process context, can now access $MY_ENV_VAR and print its value. This mechanism is also useful for storing metadata such as build timestamps, commit SHAs, or artifact names, which can be passed to later deployment or testing steps.

PowerShell Considerations

While the default shell in GitHub Actions on Linux is bash, Windows runners may use PowerShell. PowerShell versions 5.1 and below (shell: powershell) do not use UTF-8 encoding by default. When writing to $GITHUB_ENV or $GITHUB_PATH in these older PowerShell versions, you must explicitly specify UTF-8 encoding to prevent issues with special characters or non-ASCII text.

yaml jobs: legacy-powershell-example: runs-on: windows-latest steps: - shell: powershell run: | "mypath" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append

PowerShell Core versions 6 and higher (shell: pwsh) use UTF-8 by default, so this explicit encoding is not required for modern PowerShell setups. However, for scripts that must support legacy environments, the encoding specification is a critical detail.

Managing Sensitive Data with Secrets

Environment variables in GitHub Actions are not limited to non-sensitive data. Sensitive information, such as API keys, passwords, or tokens, should never be hardcoded in scripts or workflow files. Instead, GitHub provides a mechanism called Secrets.

Secrets are encrypted environment variables that are stored in the repository settings. To create a secret, navigate to the repository Settings, select "Secrets and variables," and then "Actions" from the menu. Click "New repository secret" and enter a name (e.g., API_KEY) and a value.

To use a secret within a workflow, you reference it using the secrets context. Unlike regular environment variables which use the env context (${{ env.MY_VAR }}), secrets use the secrets context (${{ secrets.API_KEY }}).

yaml steps: - name: Use Secret run: | echo "Using API key ${{ secrets.API_KEY }}"

When a secret is used in a workflow, GitHub automatically masks its value in the logs. This means that if a script prints the secret value, the log will display a masked version (e.g., ***), preventing accidental exposure of sensitive data. This masking applies to both inline usage and when the secret is passed as an environment variable.

Using secrets eliminates the risk of exposing sensitive data in workflow files or logs. It also allows for the separation of configuration from code, making it easier to rotate credentials without modifying the workflow logic.

Conclusion

The divergence between local shell environments and GitHub Actions step isolation presents a common challenge in DevOps workflows. By leveraging the GITHUB_ACTIONS environment variable as a detection mechanism, developers can create unified scripts that handle environment variable setting seamlessly across both contexts. A reusable function that exports variables for immediate use and writes to $GITHUB_ENV for persistence ensures that the same codebase can be tested locally and deployed to CI/CD without modification. This approach not only adheres to the DRY principle but also simplifies maintenance and reduces the likelihood of environment-specific bugs. Furthermore, understanding the nuances of file encoding in PowerShell and the secure handling of sensitive data via GitHub secrets ensures that environment variable management is robust, secure, and portable.

Sources

  1. GitHub Actions Environment Variables Detection
  2. GitHub Actions Workflow Commands
  3. Using GitHub Actions Environment Variables

Related Posts