While getting ready to roll out DrivnBye into private beta I spent some time working on some pipelines to automate a lot of our release work. I originally implemented the recommended way of utilizing Expo cloud to build our app binaries for android and iOS within a github action however it took less than 3 days to hit the free tier threshold for cloud builds leaving me in a pickle; do I wait a month to try building this pipeline again or do I pay $2 per build per platform? At this rate I was looking at $10-20 a day to get this pipeline working as expected plus the expected expenses when it was finally time to release to testers where we’ll be updating the app more than once a day. I needed to find a solution that was cost effective and as easy to setup as using Expo cloud.
Repurposing a Mac Mini
Months ago I purchases a used M2 Mac Mini to implement into my home lab but never got around to doing anything useful with it. The stars aligned so now I have a project and the hardware to do it.
Initial Setup
First thing to do is factory reset the mac mini and create a new apple ID for this server. I want it completely separated from my personal apple account as much as possible. Then I added that apple ID to my apple developer account as an engineer which gives it permission to submit builds to testflight.
I installed node, npm, yarn and eas-cli as well since we need to login to EAS that way we can submit out builds.
# Install Node
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
nvm install 20
# Install yarn
npm install -g yarn
# Install eas-cli
npm install -g eas-cli
# EAS Login
eas login
Sleep
Then we needed to prevent the mac mini from falling asleep, we can do this by going into the preferences and disabling sleep/ suspension when it is inactive.
Runner Setup
The guide I followed to setup the github runner: https://www.scaleway.com/en/docs/tutorials/install-github-actions-runner-mac/
Using the self hosted runner
Now that have the runner added to our github repo/ github organization we’re able to use it by adding: uses: self-hosted
in our .yml files.
EAS Build and submit pipeline
Below is an example of the pipeline we use to build and submit our builds to google play and apple test flight.
Something to note is this pipeline does not take into account OTA updates that you can perform through EAS, instead it always deploys a new version to the app store.
name: build
on:
workflow_dispatch:
inputs:
eas-release:
description: 'Automatically submit the build to EAS for store submission'
required: true
default: false
type: boolean
is-staging:
description: 'Is this a staging build?'
required: true
default: true
type: boolean
update-type:
description: 'The type of update to perform. Refer to semantic versioning for more information'
required: true
default: 'patch'
type: choice
options:
- patch
- minor
- major
env:
NODE_ENV: production
BABEL_ENV: production
EXPO_PUBLIC_EXPO_PROJECT_AUTHOR: ${{ secrets.EXPO_PUBLIC_EXPO_PROJECT_AUTHOR }}
EXPO_PUBLIC_EXPO_SHARED_BUNDLE_ID: ${{ secrets.EXPO_PUBLIC_EXPO_SHARED_BUNDLE_ID }}
EXPO_PUBLIC_EXPO_PROJECT_ID: ${{ secrets.EXPO_PUBLIC_EXPO_PROJECT_ID }}
EAS_BRANCH: ${{ github.ref == 'refs/heads/master' && 'production' || 'preview' }}
jobs:
set-version:
name: Set Version For Build
runs-on: self-hosted
outputs:
EXPO_PUBLIC_EXPO_SHARED_APP_VERSION: ${{ steps.final-set-version.outputs.EXPO_PUBLIC_EXPO_SHARED_APP_VERSION }}
ANDROID_BINARY_NAME: ${{ steps.set-binary.outputs.ANDROID_BINARY_NAME }}
ANDROID_BINARY_PATH: ${{ steps.set-binary.outputs.ANDROID_BINARY_PATH }}
IOS_BINARY_NAME: ${{ steps.set-binary.outputs.IOS_BINARY_NAME }}
IOS_BINARY_PATH: ${{ steps.set-binary.outputs.IOS_BINARY_PATH }}
steps:
- name: Checkout code
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Get latest release
id: get-version
run: |
latest_release=$(git describe --tags `git rev-list --tags --max-count=1`)
latest_release=${latest_release#v}
FOUND_VERSION=$latest_release
echo "Found version is $latest_release"
echo "FOUND_VERSION=$FOUND_VERSION" >> $GITHUB_ENV
echo "Version is $FOUND_VERSION"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Iterate version
id: iterate-version
if: ${{ github.ref == 'refs/heads/master' }}
run: |
LATEST_RELEASE=${{ env.FOUND_VERSION }}
UPDATE_TYPE=${{ github.event.inputs.update-type }}
IFS='.' read -r -a version_parts <<< "$LATEST_RELEASE"
major=${version_parts[0]}
minor=${version_parts[1]}
patch=${version_parts[2]}
if [ "$UPDATE_TYPE" == "patch" ]; then
patch=$((patch + 1))
elif [ "$UPDATE_TYPE" == "minor" ]; then
minor=$((minor + 1))
patch=0
elif [ "$UPDATE_TYPE" == "major" ]; then
major=$((major + 1))
minor=0
patch=0
fi
NEW_VERSION="$major.$minor.$patch"
if [ -z "$NEW_VERSION" ]; then
echo "Error: Failed to calculate new version."
exit 1
fi
echo "New version is $NEW_VERSION"
echo "FOUND_VERSION=$NEW_VERSION" >> $GITHUB_ENV
- name: Echo new version
id: final-set-version
run: |
VERSION=${{ env.FOUND_VERSION }}
echo "EXPO_PUBLIC_EXPO_SHARED_APP_VERSION=$VERSION" >> $GITHUB_ENV
echo "EXPO_PUBLIC_EXPO_SHARED_APP_VERSION=$VERSION" >> $GITHUB_OUTPUT
- name: Set binary information
id: set-binary
run: |
VERSION=${{ env.FOUND_VERSION }}
EAS_BRANCH=${{ env.EAS_BRANCH }}
ANDROID_BINARY_NAME=app-release-android-$VERSION-$EAS_BRANCH${{ github.event.inputs.is-staging && '-STAGING' || '' }}.aab
IOS_BINARY_NAME=app-release-iOS-$VERSION-$EAS_BRANCH${{ github.event.inputs.is-staging && '-STAGING' || '' }}.ipa
ANDROID_BINARY_PATH=$GITHUB_WORKSPACE/$ANDROID_BINARY_NAME
IOS_BINARY_PATH=$GITHUB_WORKSPACE/$IOS_BINARY_NAME
echo "ANDROID_BINARY_NAME=$ANDROID_BINARY_NAME" >> $GITHUB_OUTPUT
echo "ANDROID_BINARY_PATH=$ANDROID_BINARY_PATH" >> $GITHUB_OUTPUT
echo "IOS_BINARY_NAME=$IOS_BINARY_NAME" >> $GITHUB_OUTPUT
echo "IOS_BINARY_PATH=$IOS_BINARY_PATH" >> $GITHUB_OUTPUT
build-android:
name: EAS Build Android
runs-on: self-hosted
needs: [set-version]
env:
EXPO_PUBLIC_EXPO_SHARED_APP_VERSION: ${{ needs.set-version.outputs.EXPO_PUBLIC_EXPO_SHARED_APP_VERSION }}
ANDROID_BINARY_NAME: ${{ needs.set-version.outputs.ANDROID_BINARY_NAME }}
ANDROID_BINARY_PATH: ${{ needs.set-version.outputs.ANDROID_BINARY_PATH }}
steps:
- name: Setup repo
uses: actions/checkout@v4
- name: Setup node
uses: actions/setup-node@v4.0.2
with:
node-version: 18.x
cache: 'npm'
- name: Setup Yarn
run: npm install -g yarn
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Setup Expo
uses: expo/expo-github-action@v8
with:
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Install dependencies
run: npm ci
- name: Build Android app
run: eas build --platform android --local --profile ${{ env.EAS_BRANCH }} --output ${{ env.ANDROID_BINARY_PATH }}
- name: Upload AAB artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.ANDROID_BINARY_NAME }}
path: ${{ env.ANDROID_BINARY_PATH }}
build-ios:
name: EAS Build iOS
runs-on: self-hosted
needs: [set-version, print-configuration]
env:
EXPO_PUBLIC_EXPO_SHARED_APP_VERSION: ${{ needs.set-version.outputs.EXPO_PUBLIC_EXPO_SHARED_APP_VERSION }}
IOS_BINARY_NAME: ${{ needs.set-version.outputs.IOS_BINARY_NAME }}
IOS_BINARY_PATH: ${{ needs.set-version.outputs.IOS_BINARY_PATH }}
steps:
- name: Setup repo
uses: actions/checkout@v4
- name: Setup node
uses: actions/setup-node@v4.0.2
with:
node-version: 18.x
cache: 'npm'
- name: Setup Yarn
run: npm install -g yarn
- name: Setup Expo
uses: expo/expo-github-action@v8
with:
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Install dependencies
run: npm ci
- name: Build iOS app
run: eas build --platform ios --local --non-interactive --profile ${{ env.EAS_BRANCH }} --output ${{ env.IOS_BINARY_PATH }}
- name: Upload IPA artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.IOS_BINARY_NAME }}
path: ${{ env.IOS_BINARY_PATH }}
create-release:
name: Create GitHub Release
runs-on: self-hosted
needs: [build-android, build-ios, set-version]
env:
EXPO_PUBLIC_EXPO_SHARED_APP_VERSION: ${{ needs.set-version.outputs.EXPO_PUBLIC_EXPO_SHARED_APP_VERSION }}
IOS_BINARY_NAME: ${{ needs.set-version.outputs.IOS_BINARY_NAME }}
ANDROID_BINARY_NAME: ${{ needs.set-version.outputs.ANDROID_BINARY_NAME }}
if: ${{ github.ref == 'refs/heads/master'}}
permissions:
contents: write
actions: write
packages: write
issues: write
pull-requests: write
steps:
- name: Download Android Artifact
uses: actions/download-artifact@v4
with:
name: ${{ env.ANDROID_BINARY_NAME }}
path: ${{ github.workspace }}
- name: Download iOS Artifact
uses: actions/download-artifact@v4
with:
name: ${{ env.IOS_BINARY_NAME }}
path: ${{ github.workspace }}
- name: Create GitHub Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: v${{ env.EXPO_PUBLIC_EXPO_SHARED_APP_VERSION }}
release_name: Release v${{ env.EXPO_PUBLIC_EXPO_SHARED_APP_VERSION }}
draft: false
prerelease: false
body: |
Release v${{ env.EXPO_PUBLIC_EXPO_SHARED_APP_VERSION }}
Release Type: ${{github.event.inputs.update-type}}
- name: Upload Android Binary
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./${{ env.ANDROID_BINARY_NAME }}
asset_name: ${{ env.ANDROID_BINARY_NAME }}
asset_content_type: application/vnd.android.package-archive
- name: Upload iOS Binary
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./${{ env.IOS_BINARY_NAME }}
asset_name: ${{ env.IOS_BINARY_NAME }}
asset_content_type: application/octet-stream
eas-iOS-release:
name: EAS iOS Release
runs-on: self-hosted
needs: [create-release, set-version]
if: ${{ github.event.inputs.eas-release == 'true' }}
env:
EXPO_PUBLIC_EXPO_SHARED_APP_VERSION: ${{ needs.set-version.outputs.EXPO_PUBLIC_EXPO_SHARED_APP_VERSION }}
IOS_BINARY_PATH: ${{ needs.set-version.outputs.IOS_BINARY_PATH }}
steps:
- name: Setup Expo
uses: expo/expo-github-action@v8
with:
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: EAS iOS Submit
run: eas submit --platform ios --non-interactive --path ${{ env.IOS_BINARY_PATH}} --wait
eas-android-release:
name: EAS Android Release
runs-on: self-hosted
needs: [create-release, set-version]
if: ${{ github.event.inputs.eas-release == 'true' }}
env:
EXPO_PUBLIC_EXPO_SHARED_APP_VERSION: ${{ needs.set-version.outputs.EXPO_PUBLIC_EXPO_SHARED_APP_VERSION }}
ANDROID_BINARY_PATH: ${{ needs.set-version.outputs.ANDROID_BINARY_PATH }}
steps:
- name: Setup Expo
uses: expo/expo-github-action@v8
with:
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: EAS Android Submit
run: eas submit --platform android --non-interactive --path ${{ env.ANDROID_BINARY_PATH }} --wait
Explanation
set-version
This job takes into account the type of update you’re deploying. Using basic semantic release rules it iterates the version based off the last release on github. Since we do not want to iterate version if we’re just creating a preview build there is some logic to prevent that.
For example if the last version was v0.0.1 the next patch version would be v0.0.2
This job also takes care of setting artifact paths and names that way it is abundantly clear when a build is a staging, production or preview build.
build-android + build-ios
Just as it sounds, this job builds the actual binaries for android and iOS. Each job also uploads the binary to github as a job artifact so engineers are able to download the artifact and run it on their simulator for testing.
create-release
Now that we have our binaries, we need to then create a release in github to track the version. We only want to cut a release in github if the branch this job is run against is master
. This job also downloads and re-uploads the created binaries for this release that way they’re all packaged together if we need to manually submit a binary or for further testing.
eas-iOS-release + eas-android-release
A release has been cut, now we need to get into user’s hands to test and report back. This job takes the binaries uploaded in the build step and utilizes EAS to submit them to their respective stores.
Build Trigger
After adding this pipeline to your repo you can go into your github repo and manually trigger it using the workflow dispatch.
Conclusion
So, was it worth it? During the pipeline development I triggered a total of 66 builds, some successful and some failures. Since Expo charges per build and counts successful and failures our total if we used the cloud would be $132. Testing hasn’t event started yet and I have been the only one triggering these builds so I expect that estimation to be even higher in a month or two.
Leave a Reply