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.
Start the conversation