Automating releases with a Release Pipeline
At Memrise, we prioritize frequent updates to our apps, ideally on a weekly cadence. This approach offers numerous advantages over releasing only when significant features are ready:
- We can fix non-critical bugs as we go, eliminating the need for hot-fixing.
- We can release features early, gather feedback, and make improvements over the following weeks.
- Due to the smaller time window, releases are relatively lightweight, meaning we introduce fewer issues than if we waited longer.
However, this doesn’t come without effort. Making a release involves several steps:
- 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 ensure we haven’t introduced new issues, and fixing any issues that we might encounter.
- Monitoring the rollout, ensuring crashes and ANRs (Application not responding) are within 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 of a Mobile Release Pipeline
The main goal of a mobile release pipeline is to help us reduce the time it takes to generate a release. To achieve this, it needs 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 Fueling Our Release Pipeline
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
.
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
-
Enter
/newton build_rc
in the#android-newton
Slack channel -
Pipeline flow reaches the function
createAndroidReleaseCandidate
-
Generate release candidate version name (i.e 2022.02.01.0)
-
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.
-
Update the current Jira release, so any tickets that go into develop are marked as part of the next release.
-
Using GitHub’s API, create a
release-candidate
branch fromdevelop
and a PR to mergerelease-candidate
intorelease
(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); };
-
createAndroidReleaseCandidate
execution finishes here. However, we haven’t created our debug and release builds yet. Creating a GitHub PR in therelease-candidate
branch will trigger./gradlew assembleRelease bundleGoogleRelease
and./gradlew assembleDebug bundleGoogleDebug
, generating ouraab
files (as in, our app!), which are then updated to AppCenter. -
Now that we’ve got the URLs to our
aab
files in AppCenter, we call a Firebase function calledupdateAndroidReleaseCandidate
. This will first update the release candidate PR adding the links to our builds. -
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.
-
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.
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:
- Merge the release candidate PR, meaning now release is up-to-date with the latest changes.
- Post final release notes in a
#releases
Slack channel. - Merge
release
intodevelop
- 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 therelease-candidate
branch. - 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. -
updateAndroidCoverage
: coverage comparator tool in PRs. Discussed in detail in this article.
Conclusions: The Power of Automating Releases with a Release Pipeline
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! ❤️
Hope you enjoyed the article. For any questions, my Linkedin and Twitter accounts are the best place. Thanks for your time!
NOTE: This article was first published in the Memrise Engineering Blog. Check the blog out, there’s great content!