Setup an iOS project environment

Nowadays an iOS project is more than only a *.xcodeproj file with some self-written Objective-C or Swift files. We have a lot of direct and indirect external dependencies in our projects and each new developer on the project or the build server have to get these. Developers need these before working on the app and the build server to build and deploy the app.

ios-project-setup

Types of dependencies

We can separate the project dependencies into different categories:

Code: Because we don’t want reinvent the wheel for parts of our apps again and again, we use third-party libraries for common use cases. E.g. we use Alamofire for our network stack. Also, we want use the latest and hopefully greatest version of each dependency, to get the newest features and especially critical bug fixes almost automatically. To reach this goal you should use a dependency manager, which cares about these problems. The principle „never change a running“ system should not apply to third-party dependencies. Especially if these are responsible for critical parts of the app, like encryption.

Code Dependency Manager: To manage code dependencies in our project we currently have two famous dependency management systems in our iOS world: Cocoapods and Carthage. Both have almost the same feature set and care about two important requirements:

  1. Install the same versions of the dependencies on every system, so that every developer and the build server creates the same app artefact.
  2. Support updating a to dedicated or latest version of the dependencies

But neither Cocoapods or Carthage are bundled with macOS, therefore we have to install at least one of them. Cocoapods is available as Ruby Gem and the preferred way to install Carthage is via a Homebrew package.

Dependency Manager Management: To manage our iOS dependency manager, we should use some kind of dependency manager, too.
Cocoapods is available as Ruby Gem. So we should create a Gemfile for these type of dependencies (a Gemfile is like the Podfile for Ruby developers). We then need to use the bundler Ruby Gem to manage the dependencies in the Gemfile. Look at https://rubygems.org and https://bundler.io/ for detailed information.
We install Carthage with Homebrew via a shell command: brew install carthage. Homebrew itself is only available through a Ruby installation script. (See https://brew.sh)

Ruby: The prime dependency in this dependency chain is Ruby. The good news is that it is directly available in the latest macOS, in a 'not so old' version too! If you want the latest or a special version of Ruby you have to install it another way. Besides compiling it from source code, you can use RVM or rbenv, which provides an environment management for Ruby.

Solutions for code dependencies

After we see what dependencies our iOS project really has, we could look at possible solutions for managing them:

Under version control

If you put your code dependencies in your version control system, you will have a compile-ready state of the project in your repository. Then it’s not needed, at least for the build server, to have a way to install all the other indirect dependencies, like Cocoapods. But a developer, who wants install new or update old code dependency, will need them.

Not under version control

If you not put the code dependencies under version control, you have to provide a way that your colleagues and the build server can resolve and fetch them. The most important part is that everyone gets the same versions of each dependency, which is ensured via the *.lock / *.resolved files of each dependency manager. These files freeze the versions of used dependencies and you have to force update the dependency versions for newer versions.
In this solution, it will be also easy to add, update or remove a dependency in each step, because every developer has the needed environment for it. E.g. fastlane is also provided as a Ruby Gem, so you only need to modify the Gemfile of the project and update the Gemfile.lock.
An negative aspect is, that all of the dependencies have to always be available. Nowadays, most of the code dependency are public hosted on Github.com and will be consumed from there. If a developer decides to remove their library from Github.com you need to change the dependency or try to find another source for it.

Regardless which way you choose, in my opinion you should provide an easy way to setup the whole project environment.

Managing dependency chain

Currently there is no single right way to manage the whole dependency chain for an iOS project environment. It depends on the project and what parts should be provided for the developers and what parts they want manage by their own. Especially for Xcode (Mac App Store or direct download from developer portal) and Ruby (RVM or rbenv), each developer has their favourite way to manage it.

So, there is a part of the chain, which should already exists on the developers computer or the build server. For the rest, there are three common ways to install all the project dependencies: Shell script, Makefile and Rake script.

Base setup

The base setup, which should be already on the developers computers, normally contains Xcode, Ruby and Homebrew. It depends whether you use Cocoapods or Carthage if you need Homebrew. But we can use this as starting point.

Xcode: You can install the latest release version of Xcode via the Mac App Store (https://itunes.apple.com/app/xcode/id497799835), or via an direct download from the Apple Developer portal (https://developer.apple.com/download/). If you use the Mac App Store version, you can auto update to the latest version, but keep in mind that not every project is directly ready to run with the latest Xcode.

Ruby: You can download the source code and compile it by your own (https://www.ruby-lang.org/en/downloads/). Or you use third party tools to manage it, like rbenv or RVM. With the third party tools you can easily update or switch the current used Ruby version. So, you should really have a look at it.

Homebrew: To install Homebrew a script is provided on the project website brew.sh. If you have installed it already, Homebrew can be self upgraded with the command: brew update

Dependency setup

Ruby dependencies: External dependencies for Ruby scripts are normally be managed via the packet manager system RubyGems. With the Gem bundler it's possible to install Gems from a Gemfile like this:

ruby "~> 2.5.1"

source 'https://rubygems.org'
gem 'cocoapods', '~> 1.5.3'
gem 'fastlane', '~> 2.100.1'

To install these dependencies, you only have to run bundle install. The first run of it produces also a Gemfile.lock file, which locks the version numbers for other clients. So it's guaranteed that the same artefact is produced on every system. Therefore the Gemfile.lock should be committed in the project Git repository.

Cocoapods dependencies: Cocoapods manages the dependencies in their Podfile and Podfile.lock files. With a call of pod install you install all the right versions of the dependencies. With pod update you can update the Podfile.lock after changes in Podfile. The Podfile.lock also needs to be in you project Git repository.

Carthage dependencies: Like Cocoapods, Carthage has it's Cartfile and Cartfile.resolved with the specified versions of the dependencies. Using carthage bootstrap you can build the frameworks. The Cartfile.resolved should also be in your Git repository.

To install all the dependency a developer has to run the following commands:

# Installs bundler gem
gem install bundler

# Installs Gems with versions of Gemfile.lock
bundle install

# Installs Pods with versions of Podfile.lock
pod install

# Builds the frameworks with code versions of Cartfile.resolved
carthage boostrap 

Solutions

After you have the base setup of your iOS project environment you have to find an easy and predictable way to execute all the steps for setup the iOS project environment. You should prevent your developer to read a long potential outdated documentation.

We want do the following steps:

  • Check if Ruby is available
  • Check if Homebrew is available
  • Install Ruby Gems
  • Install Cocoapods dependencies
  • Install Carthage dependencies
  • Open for additional steps

Additional steps could be triggering the build process and run the unit test, like with fastlane:

fastlane test

So the best way would be a solution where these steps are running with the other steps, but it should be also possible to run the additional steps only by their own, because you already could have setup your project environment.

Shell script

The shell script solution is inspired by the Firefox iOS App and their solution can be found here: bootstrap.sh

The script will be executed from top to bottom and performs all the necessary steps to setup the project environment.

Pros:

  • Same syntax as manual entered commands
  • Groups manual entered commands into one file
  • Can contain checks for dependencies
  • Customisable and extendible with functions

Cons:

  • Bash syntax
  • No selective running of steps
  • No easy integration of optional additional steps

I added the command_exists function, to check if a executable is available in the current shell path.

#!/bin/bash

# Checks if executable exists in current path
command_exists () {
  command -v "$1" > /dev/null 2>&1;
}

echo "iOS project setup ..."

# Check if Ruby is installed
if ! command_exists ruby
then
  echo 'Ruby not found, please install it:'
  echo 'https://www.ruby-lang.org/en/downloads/'
  exit 1
fi

# Check if Homebrew is available
if ! command_exists brew
then
  echo 'Homebrew not found, please install it:'
  echo 'https://brew.sh/'
  exit 1
else
  echo "Update Homebrew ..."
  brew update
fi

# Install Bundler Gem
if ! command_exists bundle
then
  echo "Bundler not found, installing it ..."
  gem install bundler -v '~> 1.16.2'
else 
  echo "Update Bundler"
  gem update bundler '~> 1.16.2'
fi

# Install Ruby Gems
echo "Install Ruby Gems ..."
bundle install

# Install Cocopods dependencies
echo "Install Cocoapods"
pod install

# Install Carthage
echo "Install / Update carthage ..."
brew unlink carthage || true
brew install carthage
brew link --overwrite carthage

# Install Carthage dependencies
echo "Install Carthage Dependencies ..."
carthage bootstrap --platform ios --cache-builds

A new developer only needs to run ./project_setup.sh to setup the iOS project environment.

If you want to add additional steps, you should write additional functions for each step. With parameters at the call of ./project_setup.sh, you can control which step will be executed. For example, if we want to run our unit test, it would look like this

#!/bin/bash

echo "iOS project setup ..."

# Check if user only wants to run unit tests
only_test=false
[ "$1" == "only_test" ] && only_test=true

# Check if user wants to create build environment
# and execute the unit tests
with_test=false
[ "$1" == "with_test" ] && with_test=true

# Run fastlane unit tests
unit_test() {
  fastlane test
}

# Run only unit tests
if $only_test 
then
  unit_test
  exit 0
fi

#
# All boostrapping steps
#

# Run unit tests after project setup
if $with_test
then
  unit_test
fi

We define a unit_test function, which will executed if the first parameter is only_test or with_test. You can call the shell script with

./project_setup.sh only_test

or

./project_setup.sh with_test

For only_test, the function unit_test will be executed at the beginning and the script ends. For with_test, the whole bootstrapping steps will be executed and afterwards the function unit_test. Without parameters only the project setup will be executed.

Makefile

Inspired by the Kickstarter (Makefile) and Wikipedia (Makefile) app, a Makefile can also be a solution to execute all the steps with one command.

Unlike the shell script solution, it does not execute all the steps from top to bottom,. It executes a target block using it's name like:

make target_name

Only the commands in this target will be executed. But you can define other targets which should be executed before. So you have a chain of commands which will be executed one after the other which you can see it in the example.

You can also define a default target which will be executed if no target name is given.

Pros:

  • Same syntax as manual entered commands
  • Groups manual entered commands in one file
  • Can contain checks for dependencies
  • Selective running of steps
  • Easy integration of optional additional steps

Cons:

  • Makefile syntax
  • Limited customisable and extensible with targets

A Makefile can look like the example below and contain setup steps for each project as target.
The setup target only has other targets as dependencies and doesn’t execute anything. Targets with a colon after the target name will be executed in top to bottom order. So you can manage the execution order of your steps.

The syntax of a Makefile is a little complicated, like the checks of the existing Ruby or Homebrew binaries shows, but normally you don’t need to know more. If you interested, read more in the make documentation.

# Checks if executable exists in current path
RUBY := $(shell command -v ruby 2>/dev/null)
HOMEBREW := $(shell command -v brew 2>/dev/null)
BUNDLER := $(shell command -v bundle 2>/dev/null)

# Default target, if no is provided
default: setup

# Steps for project environment setup
setup: \
    pre_setup \
    check_for_ruby \
    check_for_homebrew \
    update_homebrew \
    install_carthage \
    install_bundler_gem \
    install_ruby_gems \
    install_carthage_dependencies \
    install_cocoapods

# Pre-setup steps
pre_setup:
    $(info iOS project setup ...)

# Check if Ruby is installed
check_for_ruby:
    $(info Checking for Ruby ...)

ifeq ($(RUBY),)
    $(error Ruby is not installed)
endif

# Check if Homebrew is available
check_for_homebrew:
    $(info Checking for Homebrew ...)

ifeq ($(HOMEBREW),)
    $(error Homebrew is not installed)
endif

# Update Homebrew
update_homebrew:
    $(info Update Homebrew ...)

    brew update

# Install Bundler Gem
install_bundler_gem:
    $(info Checking and install bundler ...)

ifeq ($(BUNDLER),)
    gem install bundler -v '~> 1.16'
else
    gem update bundler '~> 1.16'
endif

# Install Ruby Gems
install_ruby_gems:
    $(info Install RubyGems ...)

    bundle install

# Install Cocopods dependencies
install_cocoapods:
    $(info Install Cocoapods ...)

    pod install

# Install Carthage
install_carthage:
    $(info Install Carthage ...)

    brew unlink carthage || true
    brew install carthage
    brew link --overwrite carthage

# Install Carthage dependencies
install_carthage_dependencies:
    $(info Install Carthage Dependencies ...)

    carthage bootstrap --platform ios --cache-builds

Each of the targets can also be execute on their own. You have to execute make with the specific target name, like make install_ruby_gems

So, it's also easy to add additional steps in our project setup. If you want to add a unit test to it, you can define an additional target (unit_test).
If you want to execute the setup and the unit_test target together, you can define an additional target with these targets as dependency.

# Combines project setup with unit tests
setup_with_unit_test: \
    setup \
    unit_test

#
# All other boostrapping steps
#

# Run fastlane unit tests
unit_test:
    $(info Run Unittests ...)

    fastlane test

So you can call

make unit_test

to run only the unit tests, and

make setup_with_unit_test

if you need also the project setup. Especially on an build server the last command is very useful.

Rakefile

The Wordpress (Rakefile) app uses a Rakefile for its project setup. This is similar to the Makefile solution, but it uses the Ruby variant of make: rake

We don’t need a check for Ruby because Ruby and rake are preconditions on the developers system to execute the Rakefile tasks.

Otherwise, the Rakefile solution is very similar to a Makefile.
Each project setup step is in a task block and can be execute by its name, e.g. rake check_homebrew.
It is also possible to have a default task, which will be execute if you only call rake, and each of the tasks can depend on others.

Pros:

  • Groups manual entered commands in one file
  • Can contain checks for dependencies
  • Selective running of steps
  • Easy integration of optional additional steps
  • Customisation of build process via Ruby functionality

Cons:

  • Needs rake on system
  • Rakefile syntax
  • Executes shell commands over an additional Ruby function sh

You can see an example below. The main task is setup, which has other tasks as dependencies. You can define dependencies with an arrow operator pointing to the list of the dependencies.

Each of the tasks can contain any Ruby code. So if you are familiar with Ruby you can adapt this solution very quickly. But you can also see that you will mostly execute shell commands from your Ruby script. That’s why you should decide for yourself if you really need this additional abstraction layer.

# Checks if executable exists in current path
def command?(command)
    system("command -v #{command} > /dev/null 2>&1")
end

# Default target, if no is provided
task default: [:setup]

# Steps for project environment setup
task :setup => [
    :pre_setup,
    :check_for_homebrew, 
    :update_homebrew,
    :install_bundler_gem,
    :install_ruby_gems,
    :install_carthage,
    :install_cocoapods_dependencies,
    :install_carthage_dependencies,
]

# Pre-setup steps
task :pre_setup do 
    puts "iOS project setup ..."
end

# Check if Homebrew is available
task :check_for_homebrew do
    puts "Checking Homebrew ..."
    if not command?('brew')
        STDERR.puts "Homebrew not found, please install it:"
        STDERR.puts "https://brew.sh/"
        exit
    end
end

# Update Homebrew
task :update_homebrew do 
    puts "Updating Homebrew ..."
    sh "brew update"
end

# Install Bundler Gem
task :install_bundler_gem do 
    if not command?('bundle')
        sh "gem install bundler -v '~> 1.16'"
    else
        sh "gem update bundler '~> 1.16'"
    end
end

# Install Ruby Gems
task :install_ruby_gems do
    sh "bundle install"
end

# Install Cocopods dependencies
task :install_cocoapods_dependencies do
    sh "pod install"
end

# Install Carthage
task :install_carthage do 
    sh "brew unlink carthage || true"
    sh "brew install carthage"
    sh "brew link --overwrite carthage"
end

# Install Carthage dependencies
task :install_carthage_dependencies do
    sh "carthage bootstrap --platform ios --cache-builds"
end

To add additional steps, you only have to add another task, like one for the unit tests

# Run fastlane unit tests
task :unit_test do 
    sh "fastlane test"
end

You can call this directly with rake unit_test. To combine the project setup with the execution of the unit tests, you can define an extra task for it, which has the both tasks as dependencies.

# Combines project setup with unit tests
task :setup_with_unit_test => [
    :setup,
    :unit_test
]

This can be execute with

rake setup_with_unit_test

Conclusion

If you use a shell script, a Makefile, a Rakefile or something else, you will provide an easy bootstrapping script for your iOS project. This makes it much easier for new developers to start and a build server needs only a one liner to build and deploy the app. The trouble with setting this up and learning some new script languages will it be worth it.

Now, you can also easily use cloud continuous integration services like Travis CI, CircleCI or bitrise.io. Normally in the configuration of these services you will select an Xcode version and have also Ruby and Homebrew available. So your execution step will be the same as every developer does on their local machine: make setup_with_unit_test.

My preferred solution is a Makefile, because it has a integrated dependency management between the targets and is directly callable, which is not as easy in a shell script solution. It also relies on make, which comes with every macOS in contrast to rake.
If you need to execute more complex steps, which is not a strength of a Makefile, your can break the steps into multiple Shell or Ruby scripts and call them from your Makefile.

Demo project

I have provided a demo project, where you can test all three solutions on your own.

Shell script

Project setup:

./project_setup.sh

Project setup with unit tests:

./project_setup.sh with_test

Unit tests:

./project_setup.sh only_test

Makefile

Project setup:

make setup

Project setup with unit tests:

make setup_with_unit_test

Unit tests:

make unit_test

Rake

Project setup:

rake setup

Project setup with unit tests:

rake setup_with_unit_test

Unit tests:

rake unit_test

GitHub