The same commit may compile successfully on a developer machine, then resolve a different dependency graph in a fresh cloud Mac job—or silently rewrite Podfile.lock after pod install. These failures are often not caused by Xcode itself. They happen because the CocoaPods version, caches, and installation mode are not managed as build inputs. The goal should not be merely to “finish installing every time,” but to guarantee that the same inputs produce the same dependency graph and to stop immediately when they do not.
Define the Inputs for Reproducible Installs
A CocoaPods installation depends on at least five inputs: Podfile, Podfile.lock, the CocoaPods version, the Ruby and Bundler environment, and the source referenced by local development pods. Committing the lockfile is not enough if the runner can use an arbitrary CocoaPods version, because project generation formats and installation behavior may still differ.
The repository should include .ruby-version, Gemfile, Gemfile.lock, Podfile, and Podfile.lock. Pin the CocoaPods version explicitly in Gemfile instead of relying on whichever latest version happens to be installed globally:
source ENV.fetch("BUNDLE_GEM_SOURCE")
ruby "3.3.4"
gem "cocoapods", "1.15.2"
Use bundle exec pod consistently as the CI entry point. This keeps the version shown in the logs aligned with the locked environment and prevents an interactive shell from accidentally resolving the global command first.
Podfile.locklocks the dependency resolution result. It does not lock the CocoaPods executable that resolves those dependencies and generates the project.
Run CocoaPods Installation in Strict Mode
Routine builds must not run pod update. That command resolves dependencies again within the allowed version constraints. It is appropriate for manually initiated upgrade jobs, not for validating an existing commit. CI can use the following installation entry point:
#!/bin/bash
set -euo pipefail
export LANG=en_US.UTF-8
export COCOAPODS_DISABLE_STATS=true
export CP_HOME_DIR="${RUNNER_TEMP}/cocoapods-home"
bundle config set path "${RUNNER_TEMP}/bundle"
bundle check || bundle install
bundle exec pod install --deployment --clean-install
git diff --exit-code -- Podfile.lock
--deployment fails when the lockfile would need to change, while --clean-install reduces the chance that stale installation state will hide a problem. The final git diff provides a second gate: even if another script rewrites the lockfile during installation, the job cannot proceed to compilation.
If the repository contains multiple workspaces, give each workspace its own installation directory and log file. Concurrent jobs must not share the same Pods directory.
Isolate Caches Without Disabling Them Blindly
Caching is not inherently a problem; unclear boundaries are. Common sources of contamination on cloud Macs include multiple repositories sharing a CocoaPods download cache or two jobs reading from and writing to the same Bundler directory. Build cache keys from the “lockfile digest + tool version,” and write to the cache only after installation succeeds.
| Cached object | Recommended cache key | Invalidation condition |
|---|---|---|
| Bundler installation directory | Gemfile.lock digest + Ruby version | Any input changes |
| CocoaPods download cache | Podfile.lock digest + CocoaPods version | Lockfile or tool changes |
| Pods directory | Podfile.lock digest + project configuration digest | Podfile, scripts, or configuration changes |
Do not cache Pods/Manifest.lock and then use it to overwrite the result of a new installation. It records what is actually installed in the current sandbox and should be generated by the current installation. If a restored cache causes unexpected behavior, first perform an uncached installation in an isolated directory. Only when the uncached result is correct should the issue be attributed to the cache.
Verify the Dependency Graph Before Compilation
A successful installation does not guarantee a correct dependency graph. At a minimum, verify that Podfile.lock and Pods/Manifest.lock match and that there are no uncommitted changes:
test -f Podfile.lock
test -f Pods/Manifest.lock
cmp -s Podfile.lock Pods/Manifest.lock || {
echo "CocoaPods sandbox differs from Podfile.lock" >&2
exit 1
}
git status --porcelain -- Podfile Podfile.lock Gemfile Gemfile.lock
The job should fail if the final command produces any output rather than merely printing a warning. For local pods declared with :path, also record the target directory’s commit ID or content digest. Otherwise, the lockfile can remain unchanged while the directory contents drift, which still breaks reproducibility.
Create a Separate Upgrade Lane
Dependency upgrade jobs may run pod update SomePod for a specific target, but they must report three categories of changes: version changes in the lockfile, changes to SPEC CHECKSUMS, and added or removed transitive dependencies. An upgrade commit should contain dependency changes only, without unrelated application code, so reverting it can directly restore the previous lockfile.
Preserve Failure Evidence and Diagnose in Order
Do not immediately delete every cache after a failure. First preserve the CocoaPods version, Ruby version, lockfile digest, installation logs, and git status, then determine which layer failed.
- If
bundle checkfails, first verify the Ruby version and the platforms inGemfile.lock. - If strict installation requires changing the lockfile, check whether someone modified
Podfilewithout committing the corresponding lockfile. - If only jobs that restore a cache fail, perform an uncached installation in a separate directory and compare the results.
- If
Manifest.lockdoes not match, check whether installation was interrupted or multiple jobs sharedPods. - If local pod contents have drifted, add commit ID or digest validation for the source directory.
A stable CocoaPods workflow should ultimately be boring: unchanged inputs produce unchanged installation results; input changes appear in code review; and a corrupted cache affects only speed, not the dependency graph. Running these checks before Xcode compilation can usually replace a vague failure tens of minutes later with a clear failure in a matter of seconds.
Frequently asked questions
Should CI run pod install or pod update?
Routine CI should run pod install with --deployment. Reserve pod update for a dedicated dependency update job and review every resulting Podfile.lock change.
Why can results differ even when Podfile.lock is committed?
Typical causes are mismatched Ruby or CocoaPods versions, mutable local pods, damaged caches, or scripts that rewrite Podfile before installation.
Should the Pods directory be committed to Git?
Usually not. Commit Podfile, Podfile.lock, Gemfile, and Gemfile.lock, then let CI restore dependencies from those pinned inputs.
Choose a LemonVM Cloud Mac plan for your project
Lemon M4 and Lemon M4 Pro are available in Singapore, Tokyo, Seoul, Hong Kong, and the US West, with daily, weekly, monthly, and quarterly billing.