CI/CD for Android - Project Setup

May 2, 2024·

11 min read

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.

  1. 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).

    Screenshot 2023-08-01 at 21.01.24.png

  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

    Screenshot 2023-08-01 at 21.12.00.png

    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 the name 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 the action that we will use: actions/setup-java@v3. In the with block we can add additional arguments, as to which Java version we will use, and which distribution. Here we are defining a cache 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 use run. 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 example staging and production 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 our build.gradle file. The value ${{ github.run_number }} is the run number of the script. Each time we run the script, the run_number increases by one. Of course, we can add some offsets if needed. Google chkfung/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, running bundleRelease 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 the Sign 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 the secrets 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 named service_account.json, and it inserts the value that comes from the secrets (in this case the value of GOOGLE_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 or build.gradle file as an applicationId

      Screenshot 2023-08-02 at 08.58.05.png

    • 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 in app/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.

      Screenshot 2023-08-02 at 09.14.23.png

      The files contained in this folder must use the pattern whatsnew-<LOCALE> where LOCALE 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.

Screenshot 2023-08-02 at 09.25.36.png

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.

Jump to Initial Deployment