This is a series of articles. If you missed the previous parts, find the whole list of steps here: https://medium.com/@jovmit/ci-cd-for-android-requirements-2fd7f5a92a04
At this point, we are almost done with the setup. The only thing left is to add a trigger for running a GitHub Action and the action itself.
The standard way for a CI/CD setup is to run an action on each code integration (i.e., when we merge some code into a desired branch). That would trigger an action, which will usually perform some checks (like running tests) and then deploy a new version of the software. In our case, it deploys a new app version on Google Play.
To complete the remaining part of the setup, open the project in the Android Studio.
To create a GitHub Action, we have to add a script in a specific folder in the project structure. The script is a YAML file. YAML has a specific format, similar to the Python language, where even a single space can make a huge difference.
To help you with this process, I’ve included an example script in the resources, so make sure to download it while going through this lesson.
Note: If you are not using GitHub to host your code, and you cannot use GitHub Actions for CI/CD — please send me a DM, and depending on the interest I will extend this course to include other options.
- In the project explorer panel on the left side of the Android Studio — make sure you are seeing the files in a
project
mode (The default is Android, but it hides a lot of things).
2. In the root of the project, add a folder named .github (note the dot in front of the name — it will make the folder hidden). Inside that folder, add another one called workflows, and inside that one, create a new YAML file (name it as you wish). Eventually, you should have a structure similar to this
As you can see, here I have 2 scripts — one is my CI/CD action, and the other one only runs tests for the pull requests. So you can have as many scripts as you need.
3. Open the newly created YAML file. Let’s write our CI/CD script. Remember — you may download the script from the resources below this lesson as an example to look at. Feel free to copy-paste parts if you need to.
4. We start off by defining a name
name: Deploy Production
This is the name of the action that will appear in GitHub when running
5. Next, we define a trigger. Mind the indentation — it is very important in the YAML format.
on:
push:
branches: [ main ]
In this case, the trigger will be a push
to a specific branch - main
. There are other triggering options, like pull_request
for example. If we replace the push
with a pull_request
in the script above, the action will run when a new pull request is created against the targeting branch - main
6. Next, we define the jobs that are going to be executed. As we will see later, they can run in parallel, or we can make a job dependent on another one. In that case, job B will wait until job A is completed.
jobs:
test: <- this is not a keyword, just a name we choose ourselves.
...
distribute: <- same here - not a keyword. We name jobs as we want.
...
7. Our first job will run the tests. The name test:
is for our own brevity and understanding, and we can type anything. It's not a keyword that we must use.
test:
name: Unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: 17
cache: 'gradle'
- name: Grand execute permissions to gradlew
run: chmod +x gradlew
- name: Run unit tests
run: ./gradlew testDebug
Above, we see the entire job definition that will run the tests. Let’s break it down to understand what’s going on.
- name: This one is simple — it’s the name of the job.
- runs-on: we define the environment where the job will be run. Most of the time we use
ubuntu-latest
. The first thing that happens when this whole script is executed, a virtual machine with the arguments we provide will be fired up. In this case, the VM will run on the latest Ubuntu distribution. - steps: Once the VM is up and running, the steps will be executed one after another. The order of the steps is important, so keep that in mind!
- The first step is to check out the latest code version. Pay attention to the
actions/checkout@v3
part. That thing is called action. If you Google it, it will lead you to this page where you can find the whole documentation of how you can use this action. As you are noticing, we have omitted thename
argument for this step to show that the name is optional. - The second step is to set up Java. We need Java to be able to run gradle commands and to build the project at all. For this step, we provide a
name
, and then we define theaction
that we will use:actions/setup-java@v3
. In thewith
block we can add additional arguments, as to which Java version we will use, and which distribution. Here we are defining acache
as well, but it is optional and we can omit it. Again, by Googling:actions/setup-java@v3
we can see all the possibilities we can utilize when using this action. - The third step is to grant an execute permission to the
gradlew
file. This step is optional and we add it just in case. Note that in this step we userun
. That is equivalent to running a command in the shell. - Finally, we fire up a command to run the tests:
./gradlew testDebug
. Pay attention to the command here. In this example, we don't have any flavors. If we had for examplestaging
andproduction
flavors, the command would have been something like./gradlew testStagingDebug
or./gradlew testProductionDebug
depending on your need. A good approach is to run the command locally at the time of writing the script to see if that is what you need.
8. The second job is to do the actual deployment.
distribute:
name: Distribute App
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set Up JDK 17
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: 17
cache: 'gradle'
- name: Version Bump
uses: chkfung/android-version-actions@v1.2.1
with:
gradlePath: app/build.gradle
versionCode: ${{ github.run_number }}
- name: Assemble Release Bundle
run: ./gradlew bundleRelease
- name: Sign Release
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/bundle/release
signingKeyBase64: ${{ secrets.KEYSTORE }}
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
alias: ${{ secrets.APP_NAME_KEY }}
keyPassword: ${{ secrets.APP_NAME_KEY_PASSWORD }}
- name: Setup Authorization With Google Play Store
run: echo '${{ secrets.GOOGLE_PLAY_API_AUTH }}' > service_account.json
- name: Deploy to Internal Channel
uses: r0adkll/upload-google-play@v1.0.19
with:
serviceAccountJson: service_account.json
packageName: com.example.app
releaseFiles: app/build/outputs/bundle/release/app-release.aab
track: internal
status: 'completed'
whatsNewDirectory: whatsNewDirectory/
Let’s break this down as well, as this is the most important part.
- needs: Right at the definition of the
distribute
job, we notice the "needs" argument. It tells this job that it has to wait on another one, in this case on the "test". Without this argument, the jobs will run in parallel. For example, if we had a job to run the UI tests, we could run it in parallel with the other tests. - steps: Same as above. And since we have an explanation for the first 2 steps above — let’s skip them here.
- The third step in this job will bump the
versionCode
property of ourbuild.gradle
file. The value${{ github.run_number }}
is the run number of the script. Each time we run the script, therun_number
increases by one. Of course, we can add some offsets if needed. Googlechkfung/android-version-actions@v1.2.1
to find out more about this action and its possibilities. - Next, we are assembling a bundle. Back in the day, we were distributing APKs to Google Play, but that is no longer recommended. These days we are much better off distributing bundles, but the whys are out of the scope of this course. The command used is straightforward:
run: ./gradlew bundleRelease
. Again, if we happened to have flavors, the command would have been different depending on the flavor we are going to be distributing. In the case of flavors, runningbundleRelease
will make a bundle for all flavors. That's not necessarily a problem, but it will take more time and it is wasteful if we need only one flavor to distribute. A good approach is to run the command locally to test out if that is what you need. - Once we have the bundle created, we need to sign it. The first thing to figure out is where the bundle is being created (in which directory). That’s why it is important to run the proper
bundle
command locally and then find out the folder where the bundle is being created at. In case of no flavors, it will be something like this:app/build/outputs/bundle/release
. - In the
with
block of theSign Release
step, we have a few arguments. Let's see them one by one: - releaseDirectory: the folder where the bundle is being created (as described above)
- signingKeyBase64: here we need to put a reference for our Keystore. We have it inside our secrets that we created in the previous lesson.
- keyStorePassword: the password of the Keystore. Also available as a secret
- alias: the key we want to use to sign the bundle. Available in the secrets too.
- keyPassword: As the name suggests — the password of the key. Coming from the secrets as well.
- All of the values except the
releaseDirectory
come from the secrets we created in the previous lesson, and we reference them through thesecrets
reference, like so${{ secrets.NAME_OF_THE_SECRET_TO_USE }}
where NAME_OF_THE_SECRET_TO_USE is a name of a secret (KEYSTORE, KEYSTORE_PASSWORD, etc). - Next, we prepare our authorization with Google Play Console. The command
run: echo '${{ secrets.GOOGLE_PLAY_API_AUTH }}' > service_account.json
is actually creating a file namedservice_account.json
, and it inserts the value that comes from the secrets (in this case the value ofGOOGLE_PLAY_API_AUTH)
inside that file. The name is irrelevant, we can choose any name we like. Only the extension is important to be .json - Remember, this file is being created on demand when running this job, and it is temporarily available only until the job is done. Then the VM is shut down and all files created during the job run are deleted.
- Finally, we have everything we need to make the final step — ship to Google Play. We utilize
r0adkll/upload-google-play@v1.0.19
for deploying, and if you Google it - you will find the documentation of this action and all the possibilities it provides when it comes to shipping to Google Play. - serviceAccountJson: the JSON file created in the previous step. We came full circle — we had a JSON file downloaded, but we couldn’t upload files to GitHub Secrets. So we had to extract the content of the file into a secret, and here create the file again.
- packageName: This is the package of your app. Google Play uses the package names as a unique identifier for the app, and if there is already an app on Google Play with the same package name it will not allow you to upload yours. You can find your package name in your
manifest
orbuild.gradle
file as an applicationId
- releaseFiles: the full path to the .aab file (bundle) created 3 steps before (assembling the bundle). Again, it is recommended that you run the assembling command locally and then figure out the folder structure where the .aab file is going to be created. In case of no flavors, and having the application module called
app
, most probably the bundle will be located inapp/build/outputs/bundle/release/app-release.aab
- track: here we define which track we are going to deploy the app to. I highly recommend shipping it into a track that is used for internal testing (internal or alpha) where you can add a small group of people to be able to test the app manually, for example, your QA team, and then from that track promote to beta/production.
- status: what will be the status of the deployed app. If we are deploying to a channel available for internal use only, we can set this to
'completed'
. Available options are'completed', 'inProgress', 'draft', 'halted'
. You can find many more options to customize the deployment in the official documentation of the action. - whatsNewDirectory: we can create a directory in the project where we can define localized files for each different language that we want to write release notes for.
- The files contained in this folder must use the pattern
whatsnew-<LOCALE>
whereLOCALE
is using BCP 47 format. - In the example above, I have only one file targeting
en-US
. - As you can imagine, we can add jobs in the script that will generate release notes automatically based on the git history, but that’s beyond the scope of this course.
That’s it! We have our CI/CD setup in place and we can start using it. Once the script is added to the project when we push it to the remote repository — GitHub — it will automatically recognize it and it will run it. We can see the details of each job and each step in the Actions tab of the project. That’s also how we can debug and troubleshoot possible mistakes and errors.
There is only one single thing left to complete the entire setup, and that is a requirement from Google Play to do the initial app deployment manually. In fact, if we push this script to GitHub before making a manual deployment — it will fail and it will tell in the error message that the initial deployment has to be done manually. We will look into that in the last lesson of this course.