How we automate the project setup across all of our iOS project contributors

Hans Seiffert
Just Eat Takeaway-tech
5 min readDec 21, 2021

--

Working on the same codebase with multiple teams across different offices comes with its own challenges and opportunities. This blog post explains how we make sure that all developers and our CI run our iOS app project using the same environment. At the same time, it allows us to make changes to the project environment while avoiding the need to communicate these changes to everyone.

Although the approach has originated in improving the workflow for bigger teams and projects, it’s equally beneficial if used as a common interface for (multiple) smaller projects.

The sole focus of this post is on the abstraction, which we use as a common interface and the surrounding workflow. The scripts we use to install our dependencies and tools, generate code, and create the project are not discussed here to keep the scope small.

Knowledge of Make is helpful, but not required to understand the concept.

Our workflows

When reviewing the different first-time and repeated project usage scenarios, we identified that we mostly need these straightforward workflows for our project:

> bootstrap

A function which bootstraps the complete project including all dependencies. This ensures that the correct tools, be it a Ruby gem or Pod version, are always installed. It should be run when:

  • checking out the project for the first time
  • switching to another branch or commit
  • updating developer tools dependencies

> workspace

A function which only prepares the workspace. This additional function makes sense in our environment, as the complete Xcode project is generated with XcodeGen and not added to the Git repo; hence developers sometimes want to re-generate the project when working on their commits. It should be run when:

  • adding new files to the project
  • updating Pod versions

Note: Most developers would simply call bootstrap instead as it contains the same task and the overhead is low due to the usage of caching.

> translations

A function which updates the translations. It should be run when:

  • the translations changed on the server

On choosing a declarative abstraction

Considering that requirements, the project setup and developers change over time it’s beneficial to choose a declarative approach. The usage should be based on what outcome is expected instead of how that is accomplished.

For instance a function make project could create the project, including all required tools and steps. While make pods might currently do the same tasks, its naming and implementation will be obsolete once the project is no longer created with CocoaPods, but with Swift Package Manager or e.g. XcodeGen.

Developers shouldn’t always need to know what is required to set up the project, but only how it can be triggered.

Avoiding that the individual setup tasks are triggered separately, allows swift and worry-free additions and future changes to the project setup or automated tasks which are run during the project creation. The result is that nobody needs to learn about additions or other changes. Everyone will simply benefit from them when calling the declarative interface.

A practical example

Recently we introduced a common git-hook across our codebase. This hook does validate our commit messages and we want every developer on the codebase uses it. Given the nature of git-hooks, this would normally require every developer to manually copy the git-hook and paste it in their local repo copy.

There are two aspects that make the adaption, in reality, cumbersome: every developer and local repo copy. In other words: Every developer needs to be aware of this task initially as well as every time a new copy of the repo is created and then needs to copy the git-hook manually.

Making use of above mentioned declarative interface, none of our developers need to invest this manual effort and they also don’t need to be aware of the change to benefit from it. We achieve this by adding a task to make bootstrap which copies the latest git-hook from a resources folder in the repo to the actual local git-hook folder. Without any additional effort, every developer will then automatically copy the git-hook whenever they bootstrap the project (as mentioned previously, this is done when the project is initially cloned when changing branches etc.).

How did we implement it?

When looking at the example Makefile, you will notice that most tasks are implemented in their own scripts. While our Makefile acts as a common interface, we don’t consider it an ideal place to implement the tasks themselves.

A Makefile showcasing most of the functionality which we use.

As described previously, we were able to cover our main workflows with the following three main functions:

  • make bootstrap: executes all tasks which are needed to run the project on a fresh machine. This includes everything from installing developer tools like Homebrew or Gems to creating the Xcode workspace and project
  • make workspace: only executes the tasks which are required to create the Xcode workspace and project without touching the developer tools (see above); Includes XcodeGen and CocoaPods
  • make translations: executes everything which is needed to update the translations in the app. In our case, that means downloading them and generating code for them

Additionally, there are some more exotic functions, which are mostly used internally in the Makefile or only very rarely directly by developers. For example, make bootstrap is a combination of make dev-tools and make workspace.

.PHONY: bootstrap
bootstrap: dev-tools workspace

While make workspace reuses make pod-install and others, they are added to keep the Makefile clean and reusable, but not promoted across the teams.

.PHONY: workspace
workspace:
@echo ">>> Generating Xcode projects with XcodeGen ..."
./scripts/generate-projects.sh
@make pod-install

Why would one use a Makefile?

Although Make was certainly not created to be used for this purpose, but to act as an advanced build tool, we felt that it comes with a few benefits:

  • It’s preinstalled on our macOS machines.
  • Targets can be used as functions that support inheritance.
  • It can act as a common interface for multiple project-related tasks and scripts, and a default help function can be easily implemented.

Additionally, it comes with a compelling syntax, without the need to define and learn a custom tool name and some shells are able to autocomplete make targets.

In other words: Make itself is theoretically completely over equipped for the job, but it’s in practice a convenient tool, which saves us from writing our own foundations.

The good news is, you can choose whatever scripting language fits you best, because the abstraction inside the script is the actual part that matters

How did it work out for us?

We introduced this approach two years ago in our codebase and it was a huge success. Some benefits we noticed are:

  • It’s as easy for developers to change branches, and then make sure that all dependencies are set to the expected versions, as for new joiners to bootstrap the complete project.
  • Adding additional automation steps, like the generation of SwiftLint configs or image constants, was easily possible without the need for all developers to actively do something for it, or to know about these additions.
  • Our continuous integration steps use the same scripts to set up the project.
  • We don’t need extensive documentation about how to set up the project environment as the scripts act as always up-to-date documentation.

And also: Nobody complained yet that Make is used as the basis 😉

Just Eat Takeaway.com is hiring. Apply today!

--

--