←Back

Mac Mini EAS Build Github Actions Runner

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.

Bash
# 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.

YAML
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

Your email address will not be published. Required fields are marked *