GitHub recently announced general availability of its managed CI/CD platform, GitHub Actions. There is much to be excited about with this announcement, given the deep integration it offers with the rest of the GitHub platform. GitHub Actions allows you to automate all the tasks involved with software delivery. However, beyond all the goodness of GitHub Actions, there was one part of the developer experience that was extremely painful. The only way to test your workflows and actions was through the commit/push/pray method 🙏. This results in not only a painful feedback loop but also a noisy commit history:
This inspired me to create a new tool called act that allows you to run your workflows locally for fast feedback. Creating this tool required me to learn how every detail of GitHub Actions work in order to properly emulate it. My journey into the depths of GitHub Actions was extremely enlightening and at times quite surprising 😱. Let’s go back and review the highlights of this journey and learn some details of what’s going on under the hood.
HINT: GitHub Actions and Azure Pipelines have more in common than you may realize!
Let’s start by introducing the various primitives of GitHub Actions
- Events: A specific activity that triggers a workflow run. For example, activity can originate from GitHub when someone pushes a commit to a repository or when an issue or pull request is created.
- Workflows: A configurable automated process that you can set up in your repository to build, test, package, release, or deploy any project on GitHub. Workflows are made up of one or more jobs and can be scheduled or activated by an event.
- Jobs: A set of steps that execute on the same runner. You can define the dependency rules for how jobs run in a workflow file. Jobs can run at the same time in parallel or run sequentially depending on the status of a previous job. For example, a workflow can have two sequential jobs that build and test code, where the test job is dependent on the status of the build job. If the build job fails, the test job will not run.
- Step: A step is an individual task that can run commands or actions. A job configures one or more steps. Each step in a job executes on the same runner, allowing the actions in that job to share information using the filesystem.
- Action: Individual tasks that you combine as steps to create a job. Actions are the smallest portable building block of a workflow. You can create your own actions, use actions shared from the GitHub community, and customize public actions. To use an action in a workflow, you must include it as a step.
- Runner: A runner waits for available jobs. When a runner picks up a job, it runs the job's actions and reports the progress, logs, and final results back to GitHub. Runners run one job at a time.
In the below workflow file, we have a single workflow named test-and-deploy that runs anytime the push event occurs on this repository. The workflow includes two jobs named test and deploy. The deploy job depends on the test job and won’t run until the test job completes successfully. Each job defines the runner to use, ubuntu-latest in both jobs. Finally, each job has a series of steps. Some of the steps are just commands on the runner (e.g. lines 11, 12 and 19) but other steps use actions defined outside the workflow (e.g. line 10).
Now, if you’ve ever used Azure Pipelines, you may notice there are some similarities here:
- A trigger tells a pipeline to run.
- A pipeline is made up of one or more stages.
- A stage is a way of organizing jobs in a pipeline and each stage can have one or more jobs.
- Each job runs on one agent. A job can also be agentless.
- Each agent runs a job that contains one or more steps.
- A step can be a task or script and is the smallest building block of a pipeline.
- A task is a pre-packaged script that performs an action, such as invoking a REST API or publishing a build artifact.
The below azure-pipelines.yml file looks suspiciously similar to our GitHub Actions workflow:
Comparing the GitHub Actions primitives with those from Azure pipelines, we see an almost perfect match:
The similarities don’t stop there. Both GitHub Actions and Azure Pipelines support expressions in the YAML that share a similar syntax:
First Attempt — Hosted Runner
Now that we know how GitHub Actions works, let’s get back to implementing the local GitHub Action runner, act. Parsing the YAML isn’t too difficult, but running the steps will definitely be a challenge. Fortunately the self-hosted runner is available as an open source project named actions/runner. The first attempt for creating act was to launch a local copy of the hosted runner and then have act emulate the GitHub API and drive the runner with jobs.
To learn how the local runner communicates with the GitHub Actions server, I intercepted all the traffic with mitmproxy:
Unfortunately, in order to run a single workflow, the trace shows the following characteristics that would make it extremely challenging to implement:
- > 50 HTTP requests
- 10 URL patterns
- Complex (and undocumented) JSON schemas
- Session Management & JWT Authentication from Microsoft Team Foundation Server
That last bit was the most surprising 🤯
When the GitHub Actions runner communicates back to GitHub, I believe it is talking to a Microsoft Team Foundation Server. GitHub Actions appears to be a TFS Server.
Second Attempt — Docker Containers
Emulating Microsoft TFS did not seem like a feasible approach for implementing act so I needed another plan. The next best option would be to just run each step locally. This sounds like a job for containers!
This approach worked surprisingly well. Each job would consist of a long running container in which each step would be a new exec command in the job container. Additionally, for steps that used custom actions, act would then download the action repo and run it in a separate container, using the volumes from the job container to emulate the behavior of a self-hosted runner.
Everything was going great until I started testing some sample GitHub Actions. Many of the actions assumed the installation of various tools such as NodeJS, Python, Golang, or GCC. Building the Dockerfile to create an image with all these tools seemed impossible to do once, let alone keep updated. Fortunately, GitHub open sourced the Packer files used to define the Azure VM Images for self-hosted runners. I was able to convert these Packer files to use the Docker builder rather than the Azure ARM builder in the nektos/act-environments repo. Good news, I now had a Docker image that mirrored the Azure VM Image!
Building act gave me awesome insight into how GitHub Actions was built as well as how much of the platform is shared with Azure Pipelines. This makes me wonder though, how long until the two platforms converge into a single platform for defining pipelines? 🤔
In the meantime, be sure to check out act when you are building GitHub Actions or workflows to improve the feedback loop...and stay tuned for future functionality. Given the similarities between GitHub Actions and Azure Pipelines, perhaps act will soon offer a way to run Azure Pipelines locally as well!