Automating releases with a Mobile Release Pipeline

8 minute read

At Memrise we like our apps to be updated as often as possible, ideally in a weekly cadence. We’ve noticed this gives us numerous advantages versus for example releasing only when big features are ready:

  • We can fix non-critical bugs as we go, without the need for hot-fixing.
  • We can release features early and get feedback, improving them over the following weeks.
  • Because of the smaller time window, releases are relatively lightweight, meaning we introduce fewer issues than what we would introduce if we waited longer.

However, this doesn’t come for free. Making a release means there’s an effort behind it, just to name a few things:

  • Someone has to be responsible for all the release admin, such as coordinating the release cut-off, creating the Release Candidate, or uploading the approved release to Google Play Store and the App Store.
  • Regression testing by the QA team, to make sure we haven’t introduced new issues. And fixing any issues that we might encounter.
  • Monitoring the rollout, making sure crashes and ANRs (Application not responding) are in the expected threshold.

All the above is normally done by the Release Captain, with the help of the QA team. However, this process can be expensive, repetitive, and frankly, quite boring. And in engineering, what should we do when we have something expensive, repetitive, and boring? AUTOMATE IT! 🎉

The goal

We could say the main goal of a mobile release pipeline is to help us reduce the time it takes to generate a release. In order to do this, it will need to coordinate all the moving pieces / third-party services involved, which in our case are GitHub, Slack, CircleCI, Jira, AppCenter, Google Play Store, and the App Store.

Our dream was for our release pipeline (called newton in honor of one of our doggie members at the office) to do only 2 simple actions to update our apps in the store:

  • Type /newton build_rc in a Slack channel.
  • Once approved, type /newton rc_accepted in the same channel.

This is a bit over-simplified, but in reality, it is pretty much what the mobile team at Memrise has achieved.

Firebase Functions

Our Release Pipeline is fueled by Firebase Functions. What are these? From their documentation:

Cloud Functions for Firebase is a serverless framework that lets you automatically run backend code in response to events triggered by Firebase features and HTTPS requests. Your JavaScript or TypeScript code is stored in Google’s cloud and runs in a managed environment. There’s no need to manage and scale your own servers.

In a nutshell, when you write a Firebase function, you get an URL that will run the function when it is hit. You don’t need to worry about hosting, server configuration, balancing load, or general maintenance since Firebase does everything for you. Deploying functions is as simple as running a single terminal command.

To write Firebase functions, you will need to use node.js and either Javascript or Typescript. Because we’re a team of mobile developers, we chose to use Typescript since we’re more familiar with typed languages.

Just show me the code

To see how this works in practice, let’s follow a release process to see what’s done under the hood.

The entry point to our release pipeline is Slack. The first thing we’ll normally do is type /newton <command> in a channel called #android-newton or #iOS-newton. Thanks to a Slack integration, you can create a hook that will call an URL every time a message is written in these channels. And in our case, we’re going to call the URL of a Firebase function named slashCommand.

Screenshot 2022-01-26 at 22.03.30

A sneak peek of the function that handles slashCommand will give us an idea about the sort of actions that we can handle:

export function handleSlashCommand(
  command: string,
  channel: string,
  slackUrl: string,
): Promise<any> {
  if (command === undefined || command === '') {
    return Promise.reject(generateNewtonHelp());
  }

  const platform = platformForChannel(channel);
  if (platform === null) {
    return Promise.reject(`Cannot run in unsupported channel ${channel}`);
  }

  const parameters = command.split(/\s+/);
  const action = parameters[0];

  switch (action) {
    case Commands.BUILD_RC: {
      return releaseCandidateRequest(platform, parameters, slackUrl);
    }
    case Commands.HOT_FIX: {
      return hotFixRequest(platform, parameters, slackUrl);
    }
    case Commands.REJECTION: {
      return rejectionFixRequest(parameters, slackUrl);
    }
    case Commands.UPDATE_TRANSLATIONS: {
      return updateTranslationsRequest(platform, parameters, slackUrl);
    }
    case Commands.RC_ACCEPTED: {
      return rcAccepted(platform, parameters, slackUrl);
    }
    
    ...
    
    default: {
      return Promise.reject(generateNewtonHelp());
    }
  }
}

Some of these commands have just one implementation across Android and iOS, like for example updating translations:

const updateTranslationsRequest = async (
  platform: Platform,
  parameters: string[],
  slackUrl: string,
): Promise<any> => {
  await postToSlack(
    'Translations on their way https://gph.is/1ikxVLT',
    slackUrl,
  );
  const branch = 'develop';
  return triggerPullTranslations(repoName(platform, parameters), branch)
    .then(() => postToSlack(`Job triggered`, slackUrl))
    .catch(error =>
      postToSlack(`Error updating translations: ${error}`, slackUrl),
    );
};

Others, however, will be platform-specific, since the release process of an iOS app can vary from the Android one, despite having common steps.

function releaseCandidate(
  platform: Platform,
  parameters: string[],
): Promise<any> {
  switch (platform) {
    case Platform.ANDROID: {
      return createAndroidReleaseCandidate(repoName(platform, parameters));
    }
    case Platform.IOS: {
      return createIOSReleaseCandidate(repoName(platform, parameters));
    }
  }
}

Let’s now focus on the command build_rc for Android. This command will generate a release candidate that once tested by the QA team, will be released to Google Play. Let’s go step by step to understand all the things the pipeline does for us, so we can appreciate its beauty 😉

build_rc steps

  1. Enter /newton build_rc in the #android-newton Slack channel

  2. Pipeline flow reaches the function createAndroidReleaseCandidate

  3. Generate release candidate version name (i.e 2022.02.01.0)

  4. Fetch all the tickets from Jira’s API that are part of the release candidate. Once formatted this becomes our release notes, which we’ll use in quite a few places like pull requests or Slack.

  5. Update the current Jira release, so any tickets that go into develop are marked as part of the next release.

  6. Using GitHub’s API, create a release-candidate branch from develop and a PR to merge release-candidate into release (this being our main branch, the one that has the same content as the app in the store), with the version code and name updated. The body of the PR will be the release notes we fetched before.

    const createReleaseCandidatePr = async (
      repositoryName: string,
      rcVersion: string,
      formattedTickets: string,
    ) => {
      const { createNewBranchPr } = getFunctions({
        owner: 'Memrise',
        repo: repositoryName,
      });
       
      const pr = {
        inputBranch: DEVELOP_BRANCH,
        intermediateBranch: RELEASE_CANDIDATE_BRANCH,
        outputBranch: RELEASE_BRANCH,
        title: `Release candidate ${rcVersion}`,
        body: releaseCandidateBody(formattedTickets),
      };
       
      return createNewBranchPr(pr);
    };
    
  7. createAndroidReleaseCandidate execution finishes here. However, we haven’t created our debug and release builds yet. Creating a GitHub PR in the release-candidate branch will trigger ./gradlew assembleRelease bundleGoogleRelease and ./gradlew assembleDebug bundleGoogleDebug, generating our aab files (as in, our app!), which are then updated to AppCenter.

  8. Now that we’ve got the URLs to our aab files in AppCenter, we call a Firebase function called updateAndroidReleaseCandidate. This will first update the release candidate PR adding the links to our builds.

    Screenshot 2022-01-26 at 22.00.44

  9. Next, we create a GitHub Release, where we will upload the aab files along with the proguard mapping files. Whenever the release candidate is approved, we will get the release aab from here.

    Screenshot 2022-01-26 at 22.00.44

  10. Finally, we post the release notes and the build links in a Slack channel called #android-rc. It might be worth noting that we split the release notes by user-facing changes and others by using a Jira field that the pipeline looks at when formatting the release notes.

    Screenshot 2022-01-26 at 23.27.23

It is now time for our awesome QA team to do a regression on the build, finger crossed no issues are found 🤞

rc_accepted steps

On the RC being accepted, the QA team will run /newton rc_accepted in the #android-rc channel, which will:

  1. Merge the release candidate PR, meaning now release is up-to-date with the latest changes.
  2. Post final release notes in a #releases Slack channel.
  3. Merge release into develop - this way we make sure the version name and code are up-to-date, plus we add any fixes that we might have applied on top of the release-candidate branch.
  4. Mark the Jira version as released.

At this stage, the Android team can just grab the aab from GitHub Release and drag it into Google Play. There’s the potential to actually automate this step too, to streamline the process even more.

That was quite a lot of steps, right? Imagine having to do them manually every week 😅 On top of this, some of these steps are very error prone, so by delegating them to a machine we make our lives easier.

Other pipeline goodies

The great thing about the pipeline is that you can build on top of it all sort of cool automation. Some examples:

  • androidScheduledUpdateTranslations: a scheduled function that runs every morning updating the app translations.

    export const androidScheduledUpdateTranslations = functions.pubsub
      .schedule('0 9 * * 1-5')
      .timeZone('Europe/London')
      .onRun(androidUpdateTranslations);
      
    function androidUpdateTranslations(context) {
      const channel = ANDROID_NEWTON_NAME;
      const slackUrl = NEWTON_LOGS_URL;
      
      handleSlashCommand(UPDATE_TRANSLATIONS_COMMAND, channel, slackUrl).catch(
        async err => {
          await postToSlack(err, slackUrl, false);
        },
      );
    }
    
  • updateTicketWithBuildUrl: every time a ticket has a PR linked where the checks passed, we leave a comment on the Jira ticket with the build, so anyone in the company can pick it up to give it a go if they want.

    Screenshot 2022-01-26 at 23.27.23

  • updateAndroidCoverage: coverage comparator tool in PRs. Discussed in detail in this article.

Conclusions

Frequent releases are great and very important, but without proper tooling around them, they can take a serious productivity toll in the long term, on top of being error-prone. A release pipeline is a great solution for any medium-big team that wants to release their engineers from this tedious process, so they can focus on building awesome products.

PS: Building the release pipeline was a team effort by the whole mobile team at Memrise, so BIG KUDOS to everyone who took part in it, you’re awesome! ❤️

NOTE: This article was first published in the Memrise Engineering Blog. Check the blog out, there’s great content!