As we work on an Android app, we test our code regularly — hopefully with the help of automated tests. And when it's time to release a new version, we assemble the source code into an APK or AppBundle format which we deploy into the Google Play Store.
It seems like a simple process, but there are several other steps involved, like signing the package and updating the version code and name.
After a while, going through this process manually gets very tedious and tiring, so how can we automate this process?
Semaphore is the fastest continuous integration (CI) and continuous delivery (CD) service which helps you automate your workflow by building, testing, and deploying your Android project. This way you can focus on what matters: your code.
In this tutorial we will explain how to set up Semaphore for a native Android project.
If you wish to follow this tutorial you'll need the following:
- Git and a GitHub account.
- A Semaphore account. You can get one for free at semaphoreci.com.
- A Google Play Developer account to publish the app.
To get started really quickly, do this:
In Semaphore, follow the link in the top navigation to create a new project.
From the examples list, select Android (Kotlin).
Semaphore will fork a demo project and set up a complete CI/CD pipeline for you in less than a minute.
That’s it. You’re done. *mic drop*
The demo is a simple app that lists all the open source Semaphore demo projects on GitHub. It includes unit and integration tests, and has the Gradle Play Publisher plugin set up that adds a Gradle task to publish your app to the store.
You can see this app live on the Play Store.
However, I know that you’re most likely here to learn how to set up continuous integration for your own Android project. So let's see how to do that using the demo project as a foundation.
Adapting these instructions to your project should not be too difficult. But if you do run into a challenge, feel free to ask a question in the comments section. 💬
After creating your Semaphore account you'll need to create your first Semaphore project. Your Semaphore configuration is stored in a directory at the root of your repository called
.semaphore. The entry point of your CI/CD pipeline is defined by the
In this tutorial we will explain how to configure an Android CI/CD workflow by reading the configuration files. That way you’ll learn the most about Semaphore and Android continuous integration in general.
But you don't have to write YAML by hand. You can also use Semaphore’s graphical workflow builder for a more visual approach.
👉 See this public workflow to get a taste of Semaphore’s UI and the final project running.
We want to perform regular checks on the code by running a linting tool and by running the project tests, and we want those to be automated. Let's check on what can we do on Semaphore and edit the
semaphore.yml configuration file.
👉 You can see the full file of the demo project on GitHub.
agent: machine: type: e1-standard-2 os_image: ubuntu1804 containers: - name: main image: registry.semaphoreci.com/android:29
Here we're defining the environment where the build will run. We want a Linux machine with 2 vCPUs, 4 GB of RAM that runs Ubuntu 18.04 OS. We also want to run our jobs in a container that has the latest (at the time of writing) Android SDK installed, provided by Semaphore Container Registry.
Semaphore always caches the two latest stable versions of Android SDK to ensure that the machine is ready in no time, making our builds faster.
global_job_config: env_vars: - name: ADB_INSTALL_TIMEOUT value: "10" secrets: - name: android_keys prologue: commands: - checkout - mv ~/release-keystore.jks ~/$SEMAPHORE_GIT_DIR/app/release-keystore.jks - cache restore gradle-wrapper - cache restore gradle-cache - cache restore android-build
Semaphore's Global Job Config allows us to define a set of configurations that are shared across the whole pipeline. This is very helpful since it allows us to define everything in one
place instead of repeating it in every task.
Here we're defining a new environment variable
ADB_INSTALL_TIMEOUT so that
adb doesn't timeout while we're setting up the emulator and running the
We're also saying that we want to use the global secrets
android_keys that will allow us to use our keys and passwords defined as secrets, as environment variables in the build.
Specifically on the demo project we're saving the following secrets on
release-keystore.jks: the keystore file used to sign our release builds;
RELEASE_KEYSTORE_PASSWORD: our keystore password;
RELEASE_KEY_ALIAS: our keystore key alias;
RELEASE_KEY_PASSWORD: our keystore key password;
service-account-key.json: the service account key, used to deploy the demo app to the Play Store.
This is the ideal place so save sensitive data, such as keys. Be sure to use secrets on your builds instead of pushing sensitive information into your git repository.
The prologue section allow us to run a set of commands before the job begins. We're checking out the code from the git repository, moving our keystore file to the correct path and restoring our cache so builds are faster.
Here's where we can add all the build tasks we want to run. We define the jobs
of each task, and the commands we want to execute in this pipeline. Let's start
at the top.
- name: 'Build' task: jobs: - name: 'Build Project' commands: - ./gradlew bundle epilogue: on_pass: commands: - cache clear - cache store gradle-wrapper ~/.gradle/wrapper - cache store gradle-cache ~/.gradle/caches - cache store android-build ~/.android/build-cache
The first thing we want to do is to build our project by running
./gradlew to check that everything is ok. Then, in the epilogue, that will only run if this job is successful, we are refreshing the cache.
- name: 'Verification' skip: when: "pull_request !~ '.*'" task: jobs: - name: 'Analyze Code' commands: - ./gradlew lint - name: 'Unit Tests' commands: - ./gradlew test - name: 'Integration Tests' commands: # Install the required tools and the emulator itself - sdkmanager "platform-tools" "platforms;android-29" "build-tools;30.0.0" "emulator" # Install system images for the emulator - sdkmanager "system-images;android-29;google_apis;x86" # Create an emulator with the installed system images - echo no | avdmanager create avd -n test-emulator -k "system-images;android-29;google_apis;x86" # Start the emulator with no audio, boot animation, window, and with GPU acceleration off - emulator -avd test-emulator -noaudio -no-boot-anim -gpu off -no-window & # Wait for the emulator to boot completely - adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;' # Dismiss the emulator lock screen and wait 1 second for it to settle - adb shell wm dismiss-keyguard - sleep 1 # Disable window and transition animations. This is required to run UI tests correctly - adb shell settings put global window_animation_scale 0 - adb shell settings put global transition_animation_scale 0 - adb shell settings put global animator_duration_scale 0 - ./gradlew connectedAndroidTest epilogue: always: commands: - artifact push job --expire-in 2w --destination reports/ app/build/reports/
Now the real work begins. This is the verification block where we're doing a
lint check and running our tests. We also have a skip condition that tells Semaphore that we only want to run this block when a pull request is
Here's what's happening:
./gradlew lintruns the Android Lint that analyzes the code to check if there's any problems.
./gradlew testwill run all the Unit Tests on our project.
./gradlew connectedAndroidTestwill run the Integration Tests, after setting up the emulator.
Usually, this is enough to verify the code and should always be checked/ran before merging and deploying a new app version so that we're sure that we're not introducing bugs or bad code into the project. You can add more jobs here if you need to perform additional checks.
After everything completes, on the epilogue, we're uploading our test reports as artifacts so if something goes wrong with our tests we can consult the reports to see more details, making our lives a bit easier.
👉 It's important to note that every thing will run in parallel making Semaphore builds extremely fast.
Promotions are used when we want to branch out our pipeline when certain conditions are met. This is perfect for deployments.
promotions: - name: Internal Deploy pipeline_file: deploy-internal.yml auto_promote: when: "result = 'passed' AND branch = 'dev'" - name: Beta Deploy pipeline_file: deploy-beta.yml auto_promote: when: "result = 'passed' AND branch = 'master'"
We have two other pipelines, and like so two promotions. We want to promote to the pipeline that deploys our app to the internal track when we push new code to
dev, and to the beta track when we push new code to
Here's the pipeline in it's full glory. Notice how quick it is.
After verifying that everything is ok with our project we want to automate deploys.
Most of the configuration is the same as explained before. We only added a command to the global config to move the service account key to the correct path as that is needed to deploy the app and added a new block.
- name: 'Internal Deployment' task: jobs: - name: "Deploy to Play Store - Beta Track" commands: - ./gradlew publishReleaseBundle --track beta
Here we're simply running the publish task that was created by the Google Play Publisher gradle plugin. The only thing that differs from the internal pipeline is the argument
track that specifies that we want to deploy to the
internal track instead. Also, we don't have to worry about incrementing the
versionCode every time because we're using the Semaphore workflow number environment variable for that, that is always incremented when new a new build is running.
There are still somethings that you can do to improve the workflow, like adding Slack notifications, using webhooks, updating a CHANGELOG file or even creating GitHub releases, but the nice thing about Semaphore, and CI/CD tools in general, is that now you can finally focus on what matters: the code for your next Android app.
Have questions about this tutorial? Want to show off your results? Leave a comment below. 🙇♂️