Building CI/CD pipelines for iOS projects using Gitlab and Bitrise
After designing continuous integration and delivery (CI/CD) pipelines for iOS projects for almost two years now, I felt like I should share my knowledge with the community to help those starting their CI/CD journey.
First, I’ll give you a brief introduction to CI/CD pipelines and after that I’ll talk about the path we have taken to build CI/CD pipelines for iOS projects hosted on Gitlab.
What is a CI/CD pipeline?
Continuous integration and continuous delivery are practices adopted in the process of software development to increase code quality, and to enable fast development cycles and frequent software releases.
In practice, CI/CD pipelines are automated steps taken to build, test and deploy software upon code changes. Of course this is a very general definition as in reality pipelines can get very involved; You can define rules for the type of tests to run or deployment platforms to deploy to, all based on changes on different branches or even files.
CI/CD pipelines on Gitlab
Here, I am focusing on Gitlab since it is a popular code hosting choice when it comes to building CI/CD pipelines. Gitlab has a powerful built-in CI/CD tool called Gitlab CI. It is a very user-friendly software that is run through a file called .gitlab-ci.yml
placed at the repository’s root. Before jumping to explain what goes in this yml file, I would like to tell you a bit more about different components involved in running a pipeline using Gitlab CI as this will help you understand the reason behind our pipeline design.
In Gitlab CI, pipeline configuration comes in the form of jobs listed in the .gitlab-ci.yml file. These jobs are then executed by another component of Gitlab called the Gitlab Runner. When a pipeline runs, the Gitlab Runner picks up a job from the file and runs it using the execution method it is set to use and after the job finishes, the runner will send the results back to Gitlab. The execution method used by the Gitlab runner to run the jobs is called executor in Gitlab terminology. There are a number of executors that a runner can use. One of the popular executors, which is also the default one on Gitlab.com, is Docker. Using the Docker executor, the runner will run your jobs inside Docker containers. The Docker executor is very popular because it allows clean build environments with easy dependency management. All dependencies needed to build the project can be put in a user provided Docker image defined at the top of the .gitlab-ci.yml file.
Docker executor is definitely a great option, however, when it comes to iOS projects, you cannot solely rely on the Gitlab runner. Without getting too technical, this is because to build an iOS app you need the macOS kernel, whereas Docker containers run Linux kernel. So, what’s next?
From here you can take two approaches:
- First one would be to forget Docker and install the Gitlab runner on a Mac computer and take it from there. However, if you are using Gitlab.com, this approach won’t help you since you have no control on the runners and Docker is used by default. On the other hand, for enterprise-scale projects, which most likely use the enterprise version of Gitlab, doing everything in-house won’t be the first choice due to its high cost of setup and maintenance.
- Second one, which I’m very grateful for, is that fortunately there are a number of companies out there that offer infrastructure needed to build CI/CD pipelines for iOS apps. Simply put, they provide you with physical Macs or Mac VMs to build, test and deploy your iOS apps. As you may have guessed by now, we’re going to explore the second approach in this article and our winner company is Bitrise.
The reason we have picked Bitrise is that they offers a wide range of affordable mobile automation services including a free-tier for hobby projects. Besides, they do all the heavy lifting for you, and their tool has a great user experience. This is all amazing mostly because, using Bitrise, we can still use Gitlab Runners with Docker executor. But wait, is there a native integration between Gitlab CI and Bitrise? It turns out the answer is NO.
If you look that up, however, you will find that there is actually a way to connect Gitlab and Bitrise. Bitrise has a feature to set up an incoming webhook for Gitlab. This enables you to automatically trigger a build of your app on Bitrise upon such events as pushes or merge requests on Gitlab. You can check out Bitrise documents, if you are interested in using these webhooks. However, keep in mind that these webhooks have nothing to do with Gitlab CI. If you are willing to exploit the full power of Gitlab CI, these static webhooks are not the way to go. Don’t be disappointed though, as we all know “ where there’s a will there’s a way”. Keep reading to find out how we did it.
How to connect Gitlab CI to Bitrise?
After exploring Bitrise API, we decided to take advantage of their great API and write our own tool to make the bridge between Gitlab CI and Bitrise. We have developed a Bash tool called gitrise that runs in the .gitlab-ci.yml file. In a nutshell, gitrise triggers a build of your iOS project on Bitrise, monitors it and after it’s done, sends the build results back to Gitlab CI. Below you can see how the Gitlab pipeline logs will look like when using gitrise.
Thanks to Bash, gitrise is a very portable software that runs on any Unix-like operation system as long as you have Bash, jq, and curl installed. In addition, there are four mandatory parameters that have to be passed to gitrise for it to work:
- Bitrise Workflow name passed with -w flag
- Gitlab branch name with -b flag
- Project slug on Bitrise with -s flag
- Bitrise user access token to access the Bitrise API with -a flag
How to use gitrise in the pipeline?
Please note that in the rest of this article, I’ll be using pipeline and .gitlab-ci.yml file interchangeably as it is very common in the Gitlab pipeline world.
To explain the usage of gitrise in the pipeline, I am going to walk you through an example .gitlab-ci.yml file that is using gitrise. You can see the file here:
Let’s walk through the details of this file together:
image: image key at the top of the file defines the Docker image used to run the jobs. Remember we are using Gitlab runners with Docker executor. If you don’t define an image, your jobs will run in containers based on a default image used by the runner. The default image, however, may not have all your pipeline dependencies. Therefore, it is recommended to use the image key and assign an image that has all your pipeline dependencies in it. For our example above, the image should have all the gitrise dependencies: Bash, curl and jq. Note that the top level image will be the default image used to run all your jobs. If you need to use a different image for one of your jobs, you can overwrite the default image for that individual job by using the image key under the job definition.
stages: it defines the order in which you want to run the jobs. For example, in the above pipeline, since the build stage comes before test, on merge requests the first job marked with the build stage runs before the second job that is defined with the test stage.
jobs: In the above example, there are two jobs: building_the_app and run_tests. Jobs are the main components of the .gitlab-ci.yml file. There should be at least one job in the file, otherwise your pipeline will fail.
Jobs are defined using a list of parameters with script
being the main one. The script parameter is used to run shell commands, and there should be at least one script in every job. In our example jobs, other than script, we see only
and stage
parameters which respectively determine upon what events and in what order the jobs will be running in the pipeline. You can find a complete list of job parameters here.
As you can see in the example pipeline, the script
parameter is used to run gitrise in a job. Here, I have assumed the gitrise script is saved at the repository’s root but in practice it can be saved anywhere within the repo. Regarding the gitrise parameters, you can either pass static parameters (hardcoded strings) or dynamic ones using environment variables. In the example, I have used both. One of the great features of Gitlab CI is that it will provide you with some useful pre-defined environment variables in the pipeline. One of them is CI_COMMIT_REF_NAME which is basically the branch name for which the project will be built. You can use this variable to pass the branch name to gitrise. Another amazing feature of Gitlab CI is the possibility of exposing environment variables to the pipeline. These custom environment variables can be set through the Gitlab UI. This is specially useful when you are working with sensitive information in the pipeline. Using gitrise is one of those use cases, because we need to pass the Bitrise access token and the project slug to the script and believe me you wouldn’t want to hardcode those values in the .gitlab-ci.yml file.
Now that you are familiar with different parts of the sample pipeline, I can run you through what it actually does; On opening or updating a merge request, two jobs will run. First the build job runs. In this job, gitrise triggers a build on Bitrise and after the build is done, sends the results back to the Gitlab pipeline. If the first job succeeds, the test job will run next. In reality, you can use the caching capabilities of Bitrise to pass the build artifact to test jobs. Assuming you have done so, in the test job, gitrise will trigger a workflow on Bitrise to only run the tests. After the tests are finished, gitrise will send the bitrise logs back to Gitlab pipeline. And just like that you can have a pipeline for your iOS projects on Gitlab!
Summary
Gitrise is a simple elegant Bash script that benefits iOS developers who are willing to create CI/CD pipelines for their projects using Gitlab CI and Bitrise. Gitrise is useful for projects hosted on any versions of Gitlab that supports Gitlab CI. Gitrise essentially makes a bridge between Gitlab CI and Bitrise. Below is a list of the biggest advantages you get from gitrise:
- Using gitrise you are still able to use the full power of Gitlab CI for iOS projects even those hosted on Gitlab.com.
- You can pass dynamic environment variables to the build machines on Bitrise. (This is done using the -e flag. In the second job of the example pipeline, two environment variables are passed to Bitrise.)
- Last but not least, gitrise provides you with a very smooth user experience, as you don’t have to leave Gitlab at all to see the build logs. This happens because gitrise fetches the build logs from Bitrise and adds them to the Gitlab pipeline logs.
We are true believers in open source and have made gitrise available on Github. To use gitrise in your pipeline, you can simply store it in your Gitlab repo, or embed it in your pipeline image. Give it a try and let me know what you think!