May we suggest...


Using GitHub Actions for Android Continuous Integration and Delivery

At Buffer, our Android platform is heavily powered by Continuous Integration – we believe that common tasks such as releasing builds, running checks on pull requests and other frequent tasks should be automated where possible.

When it comes to our CI service, we recently made the switch to using GitHub Actions. Not only did our pricing tier have enough allowance for us to make use of Actions in a private repository, but reducing the context switching when handling CI for our pull requests helps to manage cognitive load.

Header Photo by Daniele Levis Pelusi on Unsplash

Across our two apps on the Android platform, each is currently running 4 workflows – all of which are pretty much the same across our two android apps. These workflows are currently:

  • Pull Request workflow
  • Automated production release workflow
  • Automated beta release workflow
  • A weekly dependency check workflow

Through this article I want to dive into what each one of these is doing and how they work, so you can setup similar things for your applications. But before we get started I wanted to look at a couple of important concepts around GitHub Actions which are going to be touched on throughout this article.

  • Workflow – The workflow is the foundation of your work – this is a defined set of tasks which you want to be run when a certain event occurs.
  • Trigger – This is what causes your workflow to be run, these triggers are quite flexible and allow you to have complete control of the conditions for execution. Maybe you want it to run when a commit is pushed, a pull request is opened or even when a tag is added to the codebase.

  • Job – Within your workflow, a collection of Jobs will be defined – each of these will carry out a specific role within your workflow. So if you want to run tests, you would have a job named “unitTests” which would define the operations for setting up and running the tests in your code base.
  • Step – Inside of each job, there will be one of more steps which make up the work of a job – this helps us to separate out the operations that are being carried out. So using the example of the tests from above, we could have a step to setup our test suite, a step to run our test suite, followed by a step to finalise the results from our tests.

The above touches on the core concepts and terms used within GitHub actions. To conclude, our actions will consist of a collection of workflows – each workflow will have a trigger than causes it to run, along with defining a set of jobs. Each of these jobs will then define the steps to be executed which make up that job.

Whilst in the documentation you will find more concepts than these, these will be enough for building action workflows for your own projects. With that said, let’s dive into each of our workflows and look at each of these concepts in much more detail.

Pull request workflow

When we open a Pull Request, we want to run a couple of checks on the code. In our case, we run unit tests to ensure that nothing has broken within the changes of this pull request, based on these tests. We then run some checks using Danger, this will pick up any android bugs / lint errors and label them on our pull request. The workflow for this looks like this:

There are a few things going on here, so let’s break down what is happening:

We begin here by giving our workflow a name. This will be used for identifying the workflow in the Actions console. Because of this, it’s best to be descriptive whilst also keeping it short – this will make identifying the workflow much easier.

We next declare when this workflow is going to be run. In this case we only want to it to run on pull request events – so when the pull request is opened or closed. In the documentation you can see the different on events which can be used to depict when a workflow should be run.

As mentioned previously, we need to define the jobs which make up our workflow. We use the jobs keyword to signify the start of this declaration.

The first job in our workflow is going to be the unitTest job. When it comes to jobs, its best to split them out per-responsibility. When it comes to debugging issues and maintaining your workflows, this will make things a lot smoother. We can also make this experience even better by using the name attribute to give our job a name. You can see here that I’ve used Run unit tests – this will be displayed within the Actions console. Finally, we need to declare what our job is going to be run on. This will depend on the platform that you are developing for, but for Android related tasks you can used ubuntu. Here, ubuntu-latest instructs that the latest version of ubuntu should be used. If you wish to use a specific version, you can replace version with the number.

Now that we’ve defined the outline for our first job, we need to declare some steps that will execute within it. For this, we’re going to begin by adding the steps keyword inside of our job:

We can see here that we’ve added an initial step inside of this clause. This is the GitHub Actions checkout step – which is used for checking out the current state from our repo. When running actions against code from your repository inside of your steps, you’ll need to run this action before carrying out any operations.

Because our step is going to be running the unit tests for our project, we’re going to need to configure java within our step. If you’re working with another language in your project, it’s likely you’ll need to do a similar thing here. Using the setup-java action, we can define the version of Java that we wish to apply and have this configured for us.

Now that Java is setup for our job, we can go ahead and run our tests. We’re going to define a separate step for this and give it a short name to make it clear what the step is doing. Finally, we’ll go ahead and run a bash command to run the tests in our project:

With all that in place, our full test job looks like so:

In the case of our pull request checks, that isn’t all that is running – we also have a step that will run Danger against our changed code. This job adds the following code to our workflow:

We can see here that most of the configuration here takes a similar approach as our previous job. We declare the job, what it runs on and define any steps that need to execute. Instead of setting up Java, Danger uses Ruby – so we configure that to be used for our job instead.

The main difference here is the use of a community created action, MeilCli/danger-action. We use this action to plug in danger to our workflow, providing the required arguments for this action to run. I won’t go too much into this here as not everyone will find it applicable – but Danger is definitely worth checking out if you are not currently using it.

Regardless, one useful part to pay attention to here is the last declaration for the step – the API token usage. Danger needs a GitHub API token to be able to leave comments on our pull request – we don’t want to have this hardcoded in our workflow, so we’ll access it from a secrets declaration. These secrets are stored from within GitHub and can be found within your repository by navigating to Settings > Secrets. Here you’ll have the ability to add key-value pairs for any secrets that you wish to reference within your actions using the secrets.YOUR_SECRET_KEY approach.

Release automation

Whilst manually releasing updates might not be classed as a trivial process, there are several factors that we could bear in mind when it comes to this topic:

  • Human error — Whatever the task, there is always room for human error. And whether your building, uploading or releasing an update to the play store, there is plenty of room for error here. Maybe you end up building on the wrong branch, maybe you don’t have the latest commits or maybe you forgot about the local changes you made and they accidentally end up in the release build. Whilst these may seem like obvious things to some people, everyone has bad days or a change of circumstance so we can’t rule them out completely.
  • Responsibility silo — In some teams, it’s common to have a single person responsible for the release process. Whilst this focuses the responsibility, it also creates both a knowledge and responsibility silo for this process. What happens if this person is on vacation, falls ill, leaves their job or for some other reason is not around for a release. In these situations it get’s tricky as the responsibility may then be pushed onto someone else who may not know how release work — which could introduce more problems.
  • Keystore distribution — And if you don’t have the above issue to worry about (and aren’t using Google Play App Signing) then key distribution will likely be present. Even having your application signing key on one developers machine is a security / liability risk.
  • Machine configuration — Having machines which may change configuration, or multiple machines which have different configurations already, manage the build process of your application could end in unexpected results (be it based off of the above points or not). Having a single machine and configuration responsible for this process greatly reduces any mishaps which may happen from a difference in configurations.
  • Cognitive load — And finally, a manual build and release process requires us to be aware of when updates need to go out, set aside time to handle that process and stick to that plan. It can be too easy to get caught up with “Let’s just get this fix in there too”, or getting caught up in another task and before you know it your day is up. Automating the entire release process can help remove the cognitive load that may be introduced with a manual release process.

To summarise all of the above points, fully automated continuous deployment allows for a more inclusive and frictionless process when it comes to the software development lifecycle for our applications. This not only enables our teams to becomes more flexible and focused, but also allows our users to benefit from more frequent updates to our apps. This in turn improves their experiences and accelerates the feedback loop for our product. For this reason, we utilise our CI to handle all of this for us – this is now all happening within GitHub actions.

Within GitHub actions we have two release workflows – release production and release beta. They’re about 95% the same, so I’ll deep dive into one of them and point out any differences along the way. When it comes to production releases we have the following defined:

As we can see, there is a lot going on there! Again, let’s break down each part of the flow:

We begin by defining our workflow name – this makes it easier to identify within the Actions dashboard.

Triggering the workflow

Next we need to specify when we want our workflow to run. In the case of releases, we make use of GitHub tags – so our workflow is going to be executed whenever a tag is created in the pattern of our production versioning (e.g 7.9.0):

When wanting to trigger our beta release flow, the pattern differs slightly as we use RC tags for our beta releases:

Running our unit tests

Next we’re going to start defining the jobs that are to be carried out for our workflow. We’ll begin by adding the unitTest job that we use in our pull request flow:

Prepare testing APKs

If our unit tests here pass, then we’re going to want to move on to our next job. Here, we need to prepare the APKs that will be used to run our UI tests in Firebase test lab.

The main difference here is the use of the needs property – this means that this job will not run unless the unitTest job has completed successfully. This is super handy for creating sequential flows that rely on previous steps to complete. When it comes to the steps for our job, we’re going to need to execute a couple of different things

  • Assemble the debug APK
  • Assemble our AndroidTest APK
  • Upload both of them as artifacts for our build

After checking out our code here, we start by assembling out debug APK. Here we run a short gradle command (the same as we would run from the command line / android studio) to assemble the debug APK for our app.

Once this has finished, the built APK will be available at the usual build/ director on the CI space. For the next step, we’re going to attach the build as an artifact to our job. With jobs we could lump every task into a single job, but this becomes quite unmanagable if there is a lot going on – which is why we should split our operations up into seperate jobs. Because the UI testing of our app is going to run in a seperate job than this one, we’re going to attach the built APKs to the workflow so that the next job can access them – this is because you can’t directly pass artifacts between workflow jobs. Alongside this, it also means that the files are downloadable directly from the actions dashboard for this workflow – which helps when it comes to debugging. The path we give here is the location of the built APK, this will be the same as when we build this locally.

Next we do exactly the same for our android test APK. Build it and then attach it as an artifact to our workflow.

Send test APKs to Firebase

Now that we have the APKs ready for our UI testing, we’re going to go ahead and send them to Firebase so that our test suite can be run.

Because we attached the APKs to our workflow in the previous job, we’re going to need to now fetch them so that they can be used in this job. For this we’ll use the download-artifact action. Here we just need to provide the name of the artifact that we are to be fetching, which was assigned in the last job when we originally attached them.

Before we go ahead and upload our builds to firebase for testing, we need to configure our project access to the service. We’ll begin by logging into Google cloud and setting the project ID – this is so that our google cloud account is linked to our test run. Here we’ll use the gcloud setup action, providing our service account key for google cloud, to connect to google cloud services. Once this is done, we can set the project ID to be assigned to our session with google cloud – this will be our firebase project ID.

With all of the above in place, we’re now ready to run our UI tests on Firebase. We’ll use the command below to run our tests using the provided APKs and device matrix.

You’ll notice here that we reference our –app APK that w built using assembleDebug, followed by our –test apk that was built using assembleAndroidTest. Next, we can assign the –device to be used when running our tests. For examples sake here we’ll use the single device for our tests to be run on.

Build signed APK

If our tests pass, we’re going to want to go ahead and build/sign an APK ready for upload to the Google Play Store.

The first different piece here from what we’ve previously looked at is updating the version number of our release. Because we’re relying on GitHub tags to trigger our builds, it makes sense to use this tags for the versions of our releases. Here we’ll fetch the latest tag for the current pattern and replace the line in our build.gradle file that starts with versionName with our constructed string (which will be versionName some_new_version).

With this done, we now need to configure any secrets that are used within our app. We make use of our file to contain these secrets, which aren’t available on our CI (and we don’t want to upload the file either). For this reason, during the build process we create a temporary file and populate it with values that are stored in our GitHub secrets storage.

With this done, we can go ahead and build our release APK with our secrets accessible during the build process:

With our APK built, we need to go ahead and sign in. For this we’re using a community built action which takes a:

  • Signing key – Generated signing key used for signing our APK. This can also be provided as the signing key file. Converting it to base64 encoded allows you to store it within the GitHub secrets mechanism.
  • Alias – the alias used when signing the APK
  • Key store password – the password used for the keystore of the provided signing key
  • Key password – the password used for the given key

With our APK signed, we’re almost ready to go ahead and upload it to the google play store. Again for modularity / debugging sake, we’ll go ahead and attach this to our workflow as an artifact ready for the next job.

Upload signed APK to Google Play

With our APK signed, we need to go ahead now and upload it to the Google Play Store.

Other than downloading the artifact, the main part here is the step used to upload our build to the play store. The action used for this takes the following properties:

serviceAccountJsonPlainText – Here we need to utilise a Google Play service account to handle the uploaded of the APK – once you have created a service account in the Play Console you will need to reference the provided json content here.

packageName – the package name of your application

releaseFile – the location of the APK which is to be uploaded

track – the track that the release is to be uploaded to

userFraction – the % of users that the build should be released to

whatsNewDirectory – the directory containing the whatsNew content to be uploaded with your APK

Posting a message to slack

Once all of the above is complete, we want some form of automation in place so that we don’t need to manually check if our release has gone out OK. Here we use a step that will post a message to slack to let us know a release has gone live. For this we use a community action (action-slack-notify) that takes a collection of properties to construct a slack message:

  • webhook – the webhook URL used for sending the message
  • icon – the icon to be used as the avatar for the sender of the message
  • channel – the channel that the message should be sent to
  • username – the username to be used for the message sender
  • title – the title of the message
  • message – the content of the message

Dependency Version Checks

The final workflow that we’re using is a scheduled workflow that will help us keep on top of the dependencies. Every week (we have it scheduled for midday on Wednesdays) we will get a message in slack that will let us know of any dependencies that have newer (stable) versions available. The workflow for this looks like so:

Again, let’s break this down a bit so we can understand exactly what is going on here. To begin with we assign a name to our workflow and declare the trigger that will cause it to run. For this workflow we’re using the cron schedule functionality, allowing us define when our workflow should be run in the cron format:

Within this workflow, we make us of a gradle plugin that will calculate out-of-date dependencies in our project. For this we have some custom ruling in our project build.gradle file that will depict what dependencies should be flagged as updatable versions:

What we need to do is call this dependencyUpdates task from our workflow, save the result to a file and then send the content of that file to slack. The gradle task for fetching the depencyUpdates does a great job of that, however, the format it comes back in can be a little verbose for a slack message. For that reason we created a small gradle task that will format the response – it will essentially take the dependency properties and format their name + version in a line separated string:

Now that we have these above tasks configured, we can go ahead and run our gradle tasks – we’ll begin by fetching the dependency updates:

With this run, the file with the dependency report will be available at the usual location for when that task is run. Now we can run our parseDependencyJson task, which will use that file to format the content.

Finally, in the step for sending the message to slack we take the content of our file, store its reference in a variable and store it as an environment variable. Environment variables allow us to save content which is to be used within the current step – this saves us having to create an artifact like we do when passing data between jobs.

With that in place, we then use our environment variable to create and post a message to slack:

In this post we’ve dived into the different workflows that we’re using for our Android team here at Buffer. This is a lot to take in, but hopefully there is enough information here to inspire or help you put this things in place in your own projects!

As usual, if there are any questions on anything in this article then feel free to reach out

80,000+ social media marketers trust Buffer

See all case studies