Run GitHub Actions Workflows on Single-Use Mac VMs in Orka

Learn how to convert a GitHub Actions workflow to run on single-use Mac VMs in Orka in this blog post.

GitHub Actions, first released in November 2019, is a modern (YAML-based), CI/CD system that is baked into GitHub. It provides free build environments for public projects and paid build environments for teams that are developing private projects. The fees assessed to teams with private projects are calculated according to the number of minutes that the team used their build environments to run CI jobs in a given month.

This is a standard approach for CIaaS platforms, as it offers an affordable entry point for emerging teams; however, as teams grow and their codebases become more complex, commits are made more often to a project with an ever-increasing test suite that will need to execute on each commit. As teams find themselves needing more and more build time for a given project, they may turn to self-hosting GitHub Actions runners in the pursuit of a more affordable solution.

On the face of it, this is a fine solution; however, when deployed strictly as the docs describe, GitHub Actions self-hosted runners are static – that is, they are stood up and they listen for and execute jobs indefinitely. The fact that these build environments are not torn down after a given job executes means that there is a possibility of environment drift, or a gradual changing of the state of the build environment, which can interfere with subsequent jobs.

To get around this issue, we’ll use Orka-Actions-Connect in order to spin up single-use macOS VMs that are dynamically registered as single-use (i.e. ephemeral or on-demand) runners. These dynamically-created, macOS, self-hosted runners will execute only one job before being torn down and replaced for each subsequent job.

Orka-Actions-Connect

Overview

Orka-Actions-Connect is a set of GitHub Actions for converting an existing GitHub Actions workflow, referred to in the diagram below as a “native_job,” to run on a single-use, macOS VM in Orka.

It works by creating a master/agent architecture with self-hosted runners via the use of labels. One or more long-living runners will need to be stood up in Orka and labeled “master.” Docker will need to be installed on the master runner’s host, as it will execute two pre-written GitHub Actions. Moreover, it will be responsible for monitoring the repository and, when a change is detected, executing the “spin_up” job that creates a fresh macOS VM for your workflow. This fresh macOS VM kicks off a Python script (that is pre-installed on the macOS image) at startup. The script reads the VM's custom metadata and passes those values to GitHub as it registers a fresh runner with a unique label that is specific to a given workflow.

Meanwhile, this same unique label is passed in the workflow to the native job as a runs-on label. Because Orka-Actions-Connect waits for the fresh runner to register itself before executing the native job, an appropriately labeled runner is available for the native job.

Finally, the “tear_down” job is called in the workflow, which also runs on “master,” and the macOS VM, along with the registered runner in GitHub, is torn down so that resources can be reused by future jobs.

GitHub Actions and MacStadium Orka Diagram

Setup

Setup involves three high-level steps, each of which are numbered in white in the above diagram.

1: Workflow

First, you will need to add ~30 lines of boilerplate to your existing GitHub Actions workflow. This will take care of the spin-up and tear-down bits – simply setting the stage for your original workflow to run as it currently does, but self-hosted.

For example, if your current workflow looks like this:

name: Build & Test
on:
 - push
 - pull_request
jobs:      
 native_job:
   runs-on: [macOS-latest]
   steps:
   - name: Native Job
     id: native_job
     run: echo "I'm running on GitHub"

You would need to add some boilerplate to make it look like this:

name: Build & Test
on:
 - push
 - pull_request
jobs:
 spin_up:
   runs-on: [self-hosted, master]
   steps:
   - name: Spin Up VM
     id: spin_up
     uses: jeff-vincent/orka-actions-spin-up@master
     with:
       orkaIP: http://10.221.188.100
       orkaUser: ${{ secrets.ORKA_USER }}
       orkaPass: ${{ secrets.ORKA_PASS }}
       orkaBaseImage: gha_catalina_v2.img
       githubUser: ${{ secrets.GH_USER }}
       githubPat: ${{ secrets.GH_PAT }}
       githubRepoName: your-cool-repo
   outputs:
     uniqueVMActionsTag: ${{ steps.spin_up.outputs.uniqueVMActionsTag }}
     vmName: ${{ steps.spin_up.outputs.vmName }}
     
 native_job:
   needs: spin_up
   runs-on: [self-hosted, "${{ needs.spin_up.outputs.uniqueVMActionsTag }}"]
   steps:
   - name: Native Job
     id: native_job
     run: echo "I'm running on ${{ needs.spin_up.outputs.uniqueVMActionsTag }}"
     
 tear_down:
   if: always()
   needs: [spin_up, native_job]
   runs-on: [self-hosted, master]
   steps:
   - name: Tear Down VM
     id: tear_down
     uses: jeff-vincent/orka-actions-tear-down@master
     with:
       orkaUser: ${{ secrets.ORKA_USER }}
       orkaPass: ${{ secrets.ORKA_PASS }}
       vmName: ${{ needs.spin_up.outputs.vmName }}
       githubUser: ${{ secrets.GH_USER }}
       githubPat: ${{ secrets.GH_PAT }}
       githubRepoName: your-cool-repo

Above, we added two jobs to our list of jobs to execute – spin_up and tear_down, which effectively bookend the jobs in your original workflow – i.e. the native_job. Note: All native jobs will need a runs-on value of [self-hosted, "${{ needs.spin_up.outputs.uniqueVMActionsTag }}"], and the id of each native job must be added to the needs list in the tear_down job. For example, tear_down needs both spin_up and native_job to have completed before it will run in the above.

Real-World Example

In addition to the above example, you might check out this open-source Swift SDK, with an existing GitHub Actions workflow, that's been forked and set up to work with Orka-Actions-Connect.

2: Master

You will need to stand up one or more long-living runners in your Orka environment. Once you populate the correct values in the following snippit, this will spin up the required master in Docker. If you would prefer to use Orka’s K8s sandbox for high availability masters, check out these example YAML templates.

sudo docker run -d --restart always --name github-runner \
 -e REPO_URL="https://github.com/username/your-repo" \
 -e RUNNER_NAME="master-runner" \
 -e RUNNER_TOKEN="" \
 -e RUNNER_WORKDIR="/tmp/github-runner-your-repo" \
 -e RUNNER_GROUP="your-group" \
 -e LABELS="master" \
 -v /var/run/docker.sock:/var/run/docker.sock \
 -v /tmp/github-runner-your-repo:/tmp/github-runner-your-repo \
 myoung34/github-runner:latest

NOTE: If you would prefer to stand up your master(s) outside of your Orka environment, that will work as well, although you will need to open a persistent VPN connection from your master’s host to your Orka environment.

3: macOS Image

Finally, you will need to provision and save the base image that you would like Orka to use to spin up your macOS VM agent instances. This will involve installing any and all dependencies for your build as well as setting up generally.

First, spin up an Orka VM and open a screen sharing session into the new VM. Clone Orka-Actions-Connect down your new VM, and run the following:

cd orka-actions-connect/agent && ./setup.sh

Next, open the Automator App and choose "Application." In the following view, click "Utilities" in the leftmost menu and then double click "Run Shell Script."

Automator App Screen_Choose Application

Enter the following in terminal input view:

python3 /Users/admin/agent/runner_connect.py

Then, you’ll need to save the application (probably to the desktop). Navigate to System Preferences > Users & Groups. Select Login Items and then drag and drop your new application to add it to your login items for the selected user.

System Preferences_Login Items

Next, click Login Options in this same view, and enable automatic login for your default user.

Finally, from your local machine via the Orka CLI, run:

orka image list

Collect the VM ID of the machine you've been working on. Again from the CLI, run:

orka image save

Pass the ID you just collected and name the image with the suffix .img. Finally, pass this new image file name in your GitHub Actions workflow in the spin_up job. For reference, in the above example workflow, the image we’re using is called gha_catalina_v2.img.

Execution

Now, with all of the above in place, you can kick off a build by pushing or opening a pull request to your repository.

TL;DR

GitHub Actions offers an affordable entry point for emerging teams. But this can become quite expensive as teams and their codebases grow because of the pay-for-what-you-use billing model. Self-hosted runners are a possible alternative, but they are static when stood up according to GitHub’s docs. Above, we circumvented this by using Orka-Actions-Connect to facilitate the creation and deletion of a fresh build environment for each workflow run.