Engineering Practices

Isolate Ruby and fastlane in Cloud Mac CI

Isolate Ruby and fastlane in Cloud Mac CI

On the same cloud Mac, a developer may run fastlane successfully from a terminal while a CI job reports a missing gem. After the runner is fixed, the login shell may then resolve to a different Ruby installation. These failures are usually not caused by fastlane itself. They result from path drift across the system Ruby, version managers, Bundler configuration, and the job environment. The solution is not to keep reinstalling gems, but to make the entire Ruby toolchain part of the project.

Confirm Which Ruby Is Actually Running

Do not start by running installation commands. Record the following output in both the login shell and the CI job:

command -v ruby
ruby -v
command -v bundle
bundle -v
gem env home
bundle config list
printf '%s
' "$PATH"

Pay particular attention to whether ruby and bundle come from the same prefix. If Ruby is located under a version manager directory while Bundler comes from a system directory, subsequent installations will likely write to the wrong gem path. Also check whether CI defines GEM_HOME, GEM_PATH, or BUNDLE_PATH; leftover variables can override project configuration.

A command working in an interactive terminal does not mean an automated job has the same environment. CI should start from explicit version files and project configuration, not depend on shell startup scripts happening to modify PATH.

Create a diagnostic artifact, but do not print tokens, certificate contents, or the complete environment to the logs. Paths and versions are enough to identify most resolution problems.

Pin Ruby, Bundler, and fastlane

Commit .ruby-version, Gemfile, and Gemfile.lock at the repository root. The Ruby version must be a complete, installable version rather than an ambiguous value such as 3.3. Once the team has selected a version, run:

printf '%s
' '3.3.6' > .ruby-version
bundle init
bundle add fastlane --version '~> 2.0'
bundle lock

Gemfile defines the permitted version range, while Gemfile.lock records the exact versions resolved for this installation. CI should not run bundle update before every build, because doing so defeats the purpose of the lockfile. After installation, invoke every command through Bundler:

bundle exec fastlane --version
bundle exec fastlane ios ci

Do not treat a global fastlane command as a shortcut. It may belong to a different Ruby installation, and it may change after a system update or manual troubleshooting. If the project requires a specific Bundler version, read it from the BUNDLED WITH section of Gemfile.lock, install that version, and then run bundle install.

Keep Dependencies Inside the Project Directory

Write project-level configuration to .bundle/config next to the repository instead of changing the host-wide Bundler settings:

bundle config set --local path vendor/bundle
bundle config set --local clean true
bundle install --jobs 4 --retry 2

vendor/bundle is normally excluded from Git and stored in the CI cache instead. This avoids polluting system gems while allowing different projects to use different versions. After installation, use the following checks to confirm that the complete resolution chain is intact:

test -f Gemfile.lock
test "$(ruby -e 'print RUBY_VERSION')" = "$(cat .ruby-version)"
bundle check
bundle exec ruby -e 'require "fastlane"; puts Fastlane::VERSION'

If the final command fails, do not immediately delete the entire cache. First inspect bundle config list and bundle env to confirm that the job is reading the Gemfile from the current repository rather than one in a parent directory or another file selected through BUNDLE_GEMFILE.

Simulate a Clean, Non-Interactive Environment

Before connecting the project to the production runner, validate it once with a minimal environment:

RUBY_BIN="$(dirname "$(command -v ruby)")"
env -i \
  HOME="$HOME" \
  PATH="$RUBY_BIN:/usr/bin:/bin:/usr/sbin:/sbin" \
  BUNDLE_GEMFILE="$PWD/Gemfile" \
  bundle exec fastlane --version

This step exposes paths, aliases, and environment variables that exist only in a developer’s personal shell configuration. If the command fails, fix the runner’s explicit initialization instead of making CI load the entire personal configuration.

Design Cache Keys That Cannot Cross-Contaminate Environments

Gems with native extensions must not be cached using only Gemfile.lock. Differences in the Ruby version, CPU architecture, or major and minor macOS version can make compiled files impossible to load. At a minimum, the cache key should include these fields:

Field How to obtain it Purpose
Ruby version ruby -e 'print RUBY_VERSION' Isolates ABI differences
CPU architecture uname -m Distinguishes native code builds
macOS version sw_vers -productVersion Avoids system library differences
Lockfile digest shasum -a 256 Gemfile.lock Invalidates the cache when dependencies change

Generate a stable fingerprint with:

RUBY_VERSION_KEY="$(ruby -e 'print RUBY_VERSION')"
ARCH_KEY="$(uname -m)"
OS_KEY="$(sw_vers -productVersion | cut -d. -f1,2)"
LOCK_KEY="$(shasum -a 256 Gemfile.lock | cut -d' ' -f1)"
printf '%s-%s-%s-%s
' "$RUBY_VERSION_KEY" "$ARCH_KEY" "$OS_KEY" "$LOCK_KEY"

Always run bundle check after restoring the cache. If the check fails, run bundle install to fill in the missing content. Successfully extracting a cache is not proof that the dependencies are usable.

Turn Failures into Actionable Quality Gates

The final job should run in three stages: environment validation, dependency validation, and the application lane. The first stage checks versions and paths, the second runs bundle check, and only the third invokes fastlane. Stop immediately if any stage fails so that the underlying error does not surface later as an obscure missing-plugin or constant-loading failure.

Add the following requirements to merge checks:

  • .ruby-version exactly matches the Ruby version used by the runner.
  • Gemfile.lock is committed and remains unchanged before and after the job.
  • fastlane is invoked only through bundle exec.
  • .bundle/config uses a project-level dependency directory.
  • The cache key includes the Ruby version, architecture, system version, and lockfile digest.
  • Logs retain only versions, paths, and validation results, without exposing sensitive environment values.
  • The runner and login shell use the same explicit initialization logic.

Once these constraints are in place, the Ruby toolchain changes from “a set of commands installed on the host” into an auditable build input. Future Ruby or fastlane upgrades can update the version file and lockfile in a dedicated commit, then use the same validation steps to verify the scope of the change—instead of waiting for a failure and guessing which environment the job actually used.

Frequently asked questions

Why can fastlane change even when Gemfile.lock is committed?

The job may be calling a global fastlane binary or regenerating the lockfile before execution. Always run bundle exec fastlane and treat Gemfile.lock as an immutable input.

Can vendor/bundle be reused across different Cloud Macs?

Not without a strict cache key. Include the Ruby version, CPU architecture, macOS major and minor version, and the Gemfile.lock digest to protect native extensions.

What should I check when CI works but the login shell fails?

Compare command -v ruby, ruby -v, bundle config list, and environment variables. The two sessions often load different PATH values or Ruby manager initialization scripts.

Dedicated physical node

Run your Mac mini workflows continuously in the cloud

Choose RunAMac M4, a rental period, and a deployment region to run development, build, or experimental workloads on a dedicated physical machine.

Choose a rental plan