Automated React-Native Release Tagging Using GitHub Actions
In a recent client project, we set up automated iOS releases using GitHub actions and fastlane. We had a couple different specifications for it that made it a little more challenging than normal:
- We wanted to be able to kick off the release both manually and on a schedule
- We wanted to have tags created every time a build was created, and wanted this to be automated
- We wanted to be able automate version bumps so that we didn’t have to make local changes to the version in the codebase
This led us to a solution that we’re proud of, and wanted to share with others who may be looking for a similar solution. The basic idea was that we set up a tagging process that was kicked off either through a manual workflow dispatch or a scheduled run. The tag being created triggers the release workflow, which receives the tag information and creates the release off of it.
Automated Tagging
To set this up, we create a tag.yml
file which has jobs that are triggered either on manual dispatch or on a schedule. The manual dispatch receives an input for the versionChangeType
, which can be specified as either none
, patch
, minor
, or major
. That looks like this:
name: Tag
on:
workflow_dispatch:
inputs:
versionChangeType:
description: 'Type of version bump (major, minor, patch, none)'
required: true
default: 'none'
schedule:
# Scheduled for 12PM UTC every Tuesday and Friday (8AM EST)
- cron: 0 12 * * 2,5
Now we can configure the create-new-tag
job to run. In order to do this, we need to first specify the GITHUB_AUTH_TOKEN
we use to authenticate with GitHub's API for the tag creation, as well as the base branch in the repo. The GITHUB_AUTH_TOKEN
should be put into the secrets of the repo. For instructions on how to create a personal access token, look here.
jobs:
create-new-tag:
name: Create new tag with version bump
runs-on: macos-latest
env:
GITHUB_AUTH_TOKEN: ${{ secrets.CI_GITHUB_PERSONAL_ACCESS_TOKEN }}
BASE_BRANCH: 'main'
The next step is to set up the environment variables we will need to decide what type of version bump (if any) should happen with our tagging. Because this can be triggered either with an input or on a schedule, we can’t just directly use the input value. Instead, we wrote a bash script which checks for the version change type, with none
as the default, and selectively outputs whether we need a version bump. It also outputs the branch name to tag, since we will be creating a branch and PR with our version change rather than pushing directly to the base branch. The way we set this up looked like this:
- name: Set environment variables
env:
DEFAULT_VERSION_CHANGE_TYPE: none
VERSION_CHANGE_BRANCH_TO_TAG: 'release/${{ github.event.inputs.versionChangeType }}-version-${{ github.run_id }}'
run: |
echo "VERSION_CHANGE_TYPE=${{ github.event.inputs.versionChangeType || env.DEFAULT_VERSION_CHANGE_TYPE}}" >> $GITHUB_ENV if [$VERSION_CHANGE_TYPE != 'none']
then
echo "VERSION_BUMP=true"
echo "BRANCH_TO_TAG=${{ env.VERSION_CHANGE_BRANCH_TO_TAG }}" >> $GITHUB_ENV
else
echo "VERSION_BUMP=false"
echo "BRANCH_TO_TAG=${{ env.BASE_BRANCH }}" >> $GITHUB_ENV
fi
We can now check out the main branch that we’ll be bumping the version off of:
- uses: actions/checkout@v2
with:
ref: ${{ env.BASE_BRANCH }}
And then run our fastlane command to bump the version, but only if the VERSION_BUMP
environment variable we set earlier is set to true:
- name: Bump iOS version
if: ${{ env.VERSION_BUMP == 'true'}}
run: cd ios && bundle install && bundle exec fastlane version_bump
Our fastlane command also utilizes the VERSION_CHANGE_TYPE
environment variable we have set above to know what type of version bump to do:
desc "Version bump"
lane :version_bump do |options|
if ['major', 'minor', 'patch'].include?(ENV['VERSION_CHANGE_TYPE'])
increment_version_number_in_xcodeproj(
bump_type: ENV['VERSION_CHANGE_TYPE'],
target: 'YOUR_TARGET'
)
end
end
Now that our version has been bumped in the native code, we can check out a branch and make a PR for these changes:
- name: Create Pull Request
if: ${{ env.VERSION_BUMP == 'true'}}
uses: peter-evans/create-pull-request@v3
with:
token: ${{ secrets.CI_GITHUB_PERSONAL_ACCESS_TOKEN }}
branch: '${{ env.BRANCH_TO_TAG }}'
title: 'release: ${{ github.event.inputs.versionChangeType }} version release'
commit-message: 'release: ${{ github.event.inputs.versionChangeType }} version release'
author: ${{ github.actor }} <${{ github.actor }}@users.noreply.github.com>
labels: |
release
automated pr
delete-branch: true
The last step is to actually create our tag using GitHub’s API. In order to do this, we need to write a script that checks our most recent version and bumps it in the same manner that Fastlane bumped our native code. Another important piece for mobile is to have a distinguishing number differentiating the tags so that we can create a new tag without actually bumping the version. This allows us to have as many tags on a single version as we want, so that we only need to bump the version once the most recent one has been actually sent to users on the app store. The way we did this was appending an incremental number onto each version after a -
so that we could see how many builds there were for a specific version. For example, if I created a new tag without specifying a version bump, and my most recent tag was 1.2.1-3
, then the new tag would be 1.2.1-4
. The Node script we wrote to do that looks like this:
const createTag = async () => {
// <https://docs.github.com/en/rest/reference/repos#list-repository-tags>
const response = await fetch(
// these environment variables are set for us in the github action
`${process.env.GITHUB_API_URL}/repos/${process.env.GITHUB_REPOSITORY}/tags`,
{
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${process.env.GITHUB_AUTH_TOKEN}`,
},
}
);
const data = await response.json();
// most recent tag will be the first element
const mostRecentTag = data[0].name;
// split tag on '-' to separate version from specifying number
const [tagVersion, tagVersionNumber] = mostRecentTag.split('-');
// remove v from our version so we have 1.1.0 instead of v1.1.0
const cleanTag = tagVersion.replace('v', '');
// get the new tag with the version change type
const newTag = getNewTag(cleanTag);
// if the tag didn't change, increment our specifying number
// otherwise restart our specifying numbers
const updatedTagVersionNumber =
cleanTag === newTag ? Number(tagVersionNumber) + 1 : 1;
// put it all back together into our tag name
const tagName = `v${newTag}-${updatedTagVersionNumber}`; // create tag off of the branch that we created with our version change
// <https://docs.github.com/en/rest/reference/repos#create-a-release>
await fetch(
`${process.env.GITHUB_API_URL}/repos/${process.env.GITHUB_REPOSITORY}/releases`,
{
body: JSON.stringify({
owner: 'YOUR_REPO_OWNER',
repo: 'YOUR_REPO',
tag_name: tagName,
target_commitish: process.env.BRANCH_TO_TAG,
name: `${tagName}`,
body: `Released at ${new Date(Date.now()).toISOString()}`,
}),
method: 'POST',
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${process.env.GITHUB_AUTH_TOKEN}`,
},
}
);
};const getNewTag = tag => {
// 1.1.0 becomes major=1, minor=1, patch=0
const [major, minor, patch] = tag.split('.');
if (process.env.VERSION_CHANGE_TYPE === 'major') {
return `${Number(major) + 1}.0.0`;
} else if (process.env.VERSION_CHANGE_TYPE === 'minor') {
return `${major}.${Number(minor) + 1}.0`;
} else if (process.env.VERSION_CHANGE_TYPE === 'patch') {
return `${major}.${minor}.${Number(patch) + 1}`;
} // if we didn't specify a version change type, don't change it
return tag;
};
Now that we have that added, the last step in our action is just to run it:
- name: Create tag
run: cd ci-scripts && yarn && yarn createTag
If you want to see all the pieces put together, you can check out this gist.
Releasing
The releasing process is very specific to each project, and can vary depending on how you want to do certs, what set up steps you have, how you build your app, what fastlane commands you use, etc… Because of this and for simplicity, I’ll just show the basic set up of our release file, and leave the actual building and upload process out.
In order to time our release right to ensure that the scheduled tag workflow is finished before our release workflow runs, it made the most sense to just trigger our release workflow based off when a tag was created. This also made our release process a 1 step process, so that you only had to manually kick off of the tag creation instead of waiting for it to finish and then kicking off the release. We still wanted to allow a manual dispatch with a specified ref in case you wanted to create a release off of a specific branch, tag, or commit. We also wanted to allow the user to specify whether they wanted an alpha
, beta
, or production
release, with beta
being the default for the automated releases. For production releases, those will have to be done manually. That workflow definition looks like this:
name: Release
on:
workflow_dispatch:
inputs:
releaseStage:
description: 'alpha, beta or production release (alpha does not upload to TestFlight)'
required: true
default: 'beta'
githubRef:
description: 'Github ref to build and release'
required: true
default: 'main'
release:
types: [created]
The only other set up step after that is to ensure we checked out the correct ref and set the correct release stage. That looks like this:
jobs:
release-ios:
name: Release iOS
runs-on: macos-latest
steps:
- name: Set environment variables
env:
DEFAULT_STAGE: beta
DEFAULT_REF: ${{ github.event.release.tag_name }}
run: |
echo "CHECKOUT_REF=${{ github.event.inputs.githubRef || env.DEFAULT_REF }}" >> $GITHUB_ENV
echo "RELEASE_STAGE=${{ github.event.inputs.releaseStage || env.DEFAULT_STAGE}}" >> $GITHUB_ENV
- name: Checkout tag
uses: actions/checkout@v2
with:
ref: ${{ env.CHECKOUT_REF }}... all your app specific build/upload steps
Once you get the building/releasing working in CI, you should be all set with an automated workflow of tagging and releasing both beta and production builds, with the click of a button and on a regular cadence of your choosing 🎉