How to handle shell script with GitHub action

Issue #1003

When you build iOS applications on a self-hosted Mac runner, your CI/CD pipeline is likely to encounter a frustrating issue:

Could not find 'bundler' (2.6.9) required by your Gemfile.lock

Your machine has bundler installed. You verified it works locally. Yet the GitHub Actions workflow fails with this error. This happens because of how GitHub Actions handles shell environments across steps.

How GitHub Actions Executes Steps

The key concept to understand is that each step in a GitHub Actions workflow runs in a completely separate shell instance. These shells do not share environment variables, PATH modifications, or any other state changes.

Consider this workflow:

jobs:
  build:
    steps:
      - name: Set variable
        run: export MY_VAR="hello"

      - name: Use variable
        run: echo $MY_VAR  # This outputs nothing

The variable you set in the first step is lost by the time the second step runs. The second step receives a completely fresh shell with no memory of the first step’s execution.

Login Shells vs Non-Login Shells

To understand why this matters, you need to know the difference between login shells and non-login shells.

Login Shells

When you open a terminal on your Mac, you get a login shell. This shell automatically reads startup files:

  • .zprofile (zsh, the default on newer macOS)
  • .bash_profile (bash)

These files configure your environment:

# ~/.zprofile
export PATH="$HOME/.rbenv/bin:$PATH"
eval "$(rbenv init -)"
export HOMEBREW_NO_AUTO_UPDATE=1

Your PATH is configured, rbenv is initialized, and all your development tools are available.

Non-Login Shells

By default, GitHub Actions runs a non-login shell with startup files explicitly disabled:

shell: /bin/bash --noprofile --norc -e -o pipefail {0}
                    ^^^^^^^^^^^ ^^^^
                    Disable profile and rc files

This means your startup files are never read. You get a vanilla shell with no environment setup.

The Problem in Practice

Let me walk through what happens in your iOS build process.

During Mac AMI Provisioning (Packer)

When you build your Mac image, a single continuous shell session executes:

source ~/.zprofile                    # Load environment configuration
eval "$(rbenv init -)"                # Initialize rbenv
rbenv install 3.4.4                   # Install Ruby 3.4.4
gem install bundler:2.6.9             # Install bundler to disk

Result: Bundler is installed to /Users/ec2-user/.rbenv/versions/3.4.4/bin/bundle

This works because everything happens in one shell session. The initialization persists throughout the entire provisioning process.

During GitHub Actions Workflow Execution

Your workflow has multiple steps, each running in a separate shell.

Step 1: Prepare Build (Shell Instance #1)

source ~/.zprofile
eval "$(rbenv init -)"
bundle install  # Works - rbenv is initialized in this shell

When this step completes, this shell instance closes and all its state is discarded.

Step 2: Build (Shell Instance #2 - NEW)

bundle exec fastlane distribute_dnb_test
# Fails - rbenv is NOT initialized
# System uses Ruby 2.6.0 instead of the configured 3.4.4

Bundler is still on disk, but the new shell doesn’t know where to find it because rbenv was never initialized in this shell instance.

The Solution: Initialize Environment in Each Step

The solution is straightforward: initialize your environment in every step that requires it.

Step 1: Create a Preparation Script

Create scripts/prepare-shell.sh:

#!/bin/bash
# Initialize rbenv for the current shell session
source ~/.zprofile
eval "$(rbenv init -)"

Step 2: Use the Script in Your Workflows

- name: Build
  run: |
    source scripts/prepare-shell.sh
    bundle exec fastlane distribute_dnb_test

Now each step gets a fresh shell instance and a properly initialized environment.

Understanding source vs Running Directly

You might wonder why we use source instead of running the script directly. This is important:

# Option 1: Using source (CORRECT)
source scripts/prepare-shell.sh
bundle exec fastlane distribute_dnb_test

# Option 2: Running directly (INCORRECT)
./scripts/prepare-shell.sh
bundle exec fastlane distribute_dnb_test

The difference is critical:

source scripts/prepare-shell.sh

  • Runs the script in the current shell
  • All changes to PATH and environment variables persist
  • The eval "$(rbenv init -)" affects the current shell
  • Subsequent commands see the updated environment

./scripts/prepare-shell.sh

  • Runs the script in a subprocess
  • Environment changes only exist within that subprocess
  • When the subprocess exits, all changes are lost
  • Subsequent commands run in the original environment

General Principle

GitHub Actions isolates each step intentionally to prevent steps from interfering with each other. However, this also means environment setup does not persist between steps.

When your GitHub Actions workflow fails with “command not found” errors that work fine locally, the issue is likely that your environment setup is being lost between steps. Remember that each step runs in isolation, and you need to re-initialize your environment in any step that depends on it. Using a simple helper script with source solves this elegantly and keeps your workflows maintainable.

Written by

I’m open source contributor, writer, speaker and product maker.

Start the conversation