Running GHA workflows locally with Act
Context
Act
is a local runner for GitHub Actions workflows. It works with the Docker
containerization engine to create containers as local runners for your
workflows.
It’s a good productivity tool for testing your CI/CD pipelines because instead
of creating another git branch, pushing it to the repository, and trying to
execute it using the dreaded workflow_dispatch
trigger, you can simply run it
in your CLI!
The main problem of the manual execution approach is due to pushing multiple commits to the remote repository. It may not produce the fastest iteration when trying to test workflows.
Also, consider that workflows may do things that are not expected, such as hanging or accidentally falling into an infinite loop. Doing so entails costs or using the precious free tier’s resources for testing when the actual workflow should use those.
Installing
But let’s talk about using it. For act
to work, you need docker up and running
in your local environment. Unfortunately, it cannot be another engine such as
podman
, containerd
, or other alternatives[^fn1]. After all, GitHub Actions
uses the docker backend engine to run workflows.
Use this page for guidance on installing docker.
You can check its repository on GitHub for more information on documentation and how to contribute to it. There are numerous ways of installing it, given that it’s supported on multiple platforms and as a GitHub CLI extension!
Usage
Let’s assume that you’re using something like Bash in UNIX-like OSs.
Starting out
Once installed, we use act
in our command line user interface:
act -h
Run GitHub actions locally by specifying the event name (e.g. `push`) or an action name directly.
Usage:
act [event name to run] [flags]
If no event name passed, will default to "on: push"
If actions handles only one event it will be used as default instead of "on: push"
Flags:
...
It runs all your workflows that are triggered by push
under
./.github/workflows/
directory by default. Failing to have that directory
created will result in an error:
Error: stat /Users/.../repos/carlosdmz/labs/act/.github/workflows: no such file or directory
Also, note that if you’re using MacOS, docker
will run under a Virtual Machine
and act
won’t resolve its socket location in your local environment by
default.
ERRO[0000] daemon Docker Engine socket not found and containerDaemonSocket option was not set
So, you need to provide an environment variable DOCKER_HOST
that points the
location of that socket to the application. This can be achieved by using
docker’s context feature.
docker context inspect --format '{{ .Endpoints.docker.Host }}'
unix:///Users/carlosdmz/.colima/default/docker.sock
export DOCKER_HOST=$(docker context inspect --format '{{.Endpoints.docker.Host}}')
Running actions
Let me do a brief recap on what is and how can we work with GHA.
GHA stands for GitHub Actions, and it’s a workflow tool that can be used on your repositories that are hosted on GitHub.
The best thing, actually, is that you can create it using YAML configuration,
and it has quite a complete set of docs
that you can check in order to create your own pipelines. Also, we refer these
pipelines as workflows
.
With that said, the basics for declaring a workflow would be the following:
- Create the following path from the project’s root directory with
mkdir -p ./.github/workflows/
; - And then, you create a YAML file under the directory.
Now, assuming that we have the following workflow file under
./.github/workflows/cd.yml
:
name: Continuous Deployment workflow
on:
push:
branches:
- main
jobs:
build-and-push:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- name: Build
run: echo "I'm building this project, call me later. TU, TU, TU!" && sleep 3
- name: Push
run: echo "I'm not done yet, I'm pushing artifacts! TU, TU, TU!" && sleep 3
- name: Done
run: echo "Sorry about that, I was busy. What was that?"
And then, on the project’s root directory, it’s as easy as running act
.
act
[Continuous Deployment workflow/build-and-push] π Start image=node:16-bullseye-slim
[Continuous Deployment workflow/build-and-push] π³ docker pull image=node:16-bullseye-slim platform=linux/amd64 username= forcePull=true
[Continuous Deployment workflow/build-and-push] π³ docker create image=node:16-bullseye-slim platform=linux/amd64 entrypoint=["tail" "-f" "/dev/null"] cmd=[]
[Continuous Deployment workflow/build-and-push] π³ docker run image=node:16-bullseye-slim platform=linux/amd64 entrypoint=["tail" "-f" "/dev/null"] cmd=[]
[Continuous Deployment workflow/build-and-push] β Run Main actions/checkout@v3
[Continuous Deployment workflow/build-and-push] π³ docker cp src=/Users/damazio/repos/carlosdamazio/labs/act/. dst=/Users/damazio/repos/carlosdamazio/labs/act
[Continuous Deployment workflow/build-and-push] β
Success - Main actions/checkout@v3
[Continuous Deployment workflow/build-and-push] β Run Main Build
[Continuous Deployment workflow/build-and-push] π³ docker exec cmd=[bash --noprofile --norc -e -o pipefail /var/run/act/workflow/1] user= workdir=
| I'm building this project, call me later. TU, TU, TU!
[Continuous Deployment workflow/build-and-push] β
Success - Main Build
[Continuous Deployment workflow/build-and-push] β Run Main Push
[Continuous Deployment workflow/build-and-push] π³ docker exec cmd=[bash --noprofile --norc -e -o pipefail /var/run/act/workflow/2] user= workdir=
| I'm not done yet, I'm pushing artifacts! TU, TU, TU!
[Continuous Deployment workflow/build-and-push] β
Success - Main Push
[Continuous Deployment workflow/build-and-push] β Run Main Done
[Continuous Deployment workflow/build-and-push] π³ docker exec cmd=[bash --noprofile --norc -e -o pipefail /var/run/act/workflow/3] user= workdir=
| Sorry about that, I was busy. What was that?
[Continuous Deployment workflow/build-and-push] β
Success - Main Done
[Continuous Deployment workflow/build-and-push] π Job succeeded
And this is what act
does, it runs your workflows in a container so it makes
your workflow debugging life a little bit easier.
By default, when you run a bare command, act
triggers the push
action on our
repo’s default branch. So whenever a commit to the main
branch is pushed and
synced in GitHub, it’ll trigger this workflow we’ve set.
However, we only did the CD part of the CI/CD process, which involves continuous deployment of the project when changes are in fact merged in our repository.
The most important process, IMO, is the Continuous Integration, which will make sure that changes are conformal with quality standards we have set for it.
Minimally, we’d like to have our code:
- Formatted according to the programming language standards;
- Linted according to a set of rules that the code must follow;
- Tested according to assertions.
So we’ll introduce another file that’ll go under ./.github/workflows/ci.yml
:
name: Continuous Integration workflow
on:
pull_request:
jobs:
ci:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- name: Lint
run: echo "I think this PR follows the language convention... sorta." && sleep 3
- name: Format
run: echo "Let me automagically fix the format!" && sleep 3
- name: Unit test
run: echo "It's not breaking anything, at the very least!" && sleep 3
- name: Integration test
run: echo "I can't fit pieces together. Sorry about that, it's no use." && exit 1
So this workflow will emulate a CI process, and we’ll be forcing an error during the last step in the integration test.
To execute this workflow in act
, you need to specify which trigger you want to
emulate, since the bare act
command defaults to the push
trigger. In our
case, we’d like to trigger a pull_request
:
act pull_request
[Continuous Integration workflow/ci] π Start image=node:16-bullseye-slim
[Continuous Integration workflow/ci] π³ docker pull image=node:16-bullseye-slim platform=linux/amd64 username= forcePull=true
[Continuous Integration workflow/ci] π³ docker create image=node:16-bullseye-slim platform=linux/amd64 entrypoint=["tail" "-f" "/dev/null"] cmd=[]
[Continuous Integration workflow/ci] π³ docker run image=node:16-bullseye-slim platform=linux/amd64 entrypoint=["tail" "-f" "/dev/null"] cmd=[]
[Continuous Integration workflow/ci] β Run Main actions/checkout@v3
[Continuous Integration workflow/ci] π³ docker cp src=/Users/damazio/repos/carlosdamazio/labs/act/. dst=/Users/damazio/repos/carlosdamazio/labs/act
[Continuous Integration workflow/ci] β
Success - Main actions/checkout@v3
[Continuous Integration workflow/ci] β Run Main Lint
[Continuous Integration workflow/ci] π³ docker exec cmd=[bash --noprofile --norc -e -o pipefail /var/run/act/workflow/1] user= workdir=
| I think this PR follows the language convention... sorta.
[Continuous Integration workflow/ci] β
Success - Main Lint
[Continuous Integration workflow/ci] β Run Main Format
[Continuous Integration workflow/ci] π³ docker exec cmd=[bash --noprofile --norc -e -o pipefail /var/run/act/workflow/2] user= workdir=
| Let me automagically fix the format!
[Continuous Integration workflow/ci] β
Success - Main Format
[Continuous Integration workflow/ci] β Run Main Unit test
[Continuous Integration workflow/ci] π³ docker exec cmd=[bash --noprofile --norc -e -o pipefail /var/run/act/workflow/3] user= workdir=
| It's not breaking anything, at the very least!
[Continuous Integration workflow/ci] β
Success - Main Unit test
[Continuous Integration workflow/ci] β Run Main Integration test
[Continuous Integration workflow/ci] π³ docker exec cmd=[bash --noprofile --norc -e -o pipefail /var/run/act/workflow/4] user= workdir=
| I can't fit pieces together. Sorry about that, it's no use.
[Continuous Integration workflow/ci] β Failure - Main Integration test
[Continuous Integration workflow/ci] exitcode '1': failure
[Continuous Integration workflow/ci] π Job failed
Error: Job 'ci' failed
So this is the pattern that act
offers, which is to run workflows according to
triggers that they’re set for execution. Let’s see what are the other things
that it may offer to us, shall we?
Running specific jobs instead of the whole workflow
Let’s assume we have a huge workflow (10+ steps), or a step that we know it’s working fine, but it’s time-consuming.
We may want to have a shortcut to just execute a single step that’s being
problematic for us, right? It’s a lucky day for us, act
has gotten us covered
for that.
During the CI workflow, the integration test
was being a bit problematic to
us, reporting a failure that shouldn’t be there.
So let’s debug it by using the previous command, but now we’re passing the -j
flag and then the name of the step in single quotes as in the following:
act pull_request -j 'Integration test'
...
[Continuous Integration workflow/ci] β Run Main Integration test
[Continuous Integration workflow/ci] π³ docker exec cmd=[bash --noprofile --norc -e -o pipefail /var/run/act/workflow/4] user= workdir=
| I can't fit pieces together. Sorry about that, it's no use.
[Continuous Integration workflow/ci] β Failure - Main Integration test
[Continuous Integration workflow/ci] exitcode '1': failure
...
By doing some adjustments in the step, you can give it a try and should be good to go!
- run: echo "I can't fit pieces together. Sorry about that, it's no use." && exit 1
+ run: echo "Everything looks good!"
act pull_request -j 'Integration test'
...
[Continuous Integration workflow/ci] β Run Main Integration test
[Continuous Integration workflow/ci] π³ docker exec cmd=[bash --noprofile --norc -e -o pipefail /var/run/act/workflow/4] user= workdir=
| Everything looks good!
[Continuous Integration workflow/ci] β
Success - Main Integration test
...
Listing available workflows
If you want to list all the workflows along with their trigger events that are associated with the repository:
act -l
Stage Job ID Job name Workflow name Workflow file Events
0 build-and-push build-and-push Continuous Deployment workflow cd.yml push
0 ci ci Continuous Integration workflow ci.yml pull_request
If you want to list all workflows for a specific event:
act pull_request -l
Stage Job ID Job name Workflow name Workflow file Events
0 ci ci Continuous Integration workflow ci.yml pull_request
Using secrets
Usually, when we’re dealing with these workflows, we’re interacting with other services such as cloud providers, quality gating services, etc.
They use authentication to make sure that’s us doing our own thing. These
secrets can be stored on the repo’s settings in GitHub and then you can refer to
it using the ${{ secrets.<KEY> }}
directive.
However, how can we emulate it with act
, since we don’t have anywhere to store
or to access? Once again, it’s gotten us covered.
In our cd.yml
example, let’s assume that we want to push a specific build
artifact into a service that requires a secret in order to accomplish that.
name: Push
- run: echo "I'm not done yet, I'm pushing artifacts! TU, TU, TU!" && sleep 3
+ env:
+ SECRET: ${{ secrets.WAT }}
+ run: |
+ [ -n "$SECRET" ] && echo "You may pass! ;)" && exit 0 || echo "TRESPASSER! GET BACK! D-:<" && exit 1
If we try running the workflow without providing the secret, we won’t get past it:
act
...
[Continuous Deployment workflow/build-and-push] β Run Main Push
[Continuous Deployment workflow/build-and-push] π³ docker exec cmd=[bash --noprofile --norc -e -o pipefail /var/run/act/workflow/2] user= workdir=
| TRESPASSER! GET BACK! D-:<
[Continuous Deployment workflow/build-and-push] β Failure - Main Push
[Continuous Deployment workflow/build-and-push] exitcode '1': failure
[Continuous Deployment workflow/build-and-push] π Job failed
Error: Job 'build-and-push' failed
...
There are two ways for us to pass the required secret:
Provide it through the
act
CLI flag-s
in a key-value manner, likeact -s WAT=foo
. The drawback in doing so is because the secret will be exposed in the logs, history, anything that records the inputs and outputs of the shell. So then, the safest alternative would be the following.Provide an
.env
file to the CLI option--secret-file
. This way, it’s guaranteed that nothing will be issued back tostdout
.
So, it’s as simple as creating an .env
file with a secret like WAT=foo
and
then executing act
with the file being provided in the --secret-file
option:
act --secret-file ./.env
...
[Continuous Deployment workflow/build-and-push] β Run Main Push
[Continuous Deployment workflow/build-and-push] π³ docker exec cmd=[bash --noprofile --norc -e -o pipefail /var/run/act/workflow/2] user= workdir=
| You may pass! ;)
[Continuous Deployment workflow/build-and-push] β
Success - Main Push
...
So the secrets will make it easier for you to interact with services outside the GHA realm so you can harnest the testing even further.
The last thing that’s missing, really, is the ability to push artifacts, and of
course, act
’s gotten us covered.
Artifacts handling
There are some workflows in which we may store artifacts of builds or anything that we intend to keep regarding a project. So how does it work? Take a look.
Assuming we have the following code:
package main
import "fmt"
func main() {
fmt.Println("Hello, world!")
}
We want to build it and store the binary in our artifacts. Let’s assume that we
have the following artifact.yml
file like this:
name: Artifact demonstration workflow
on:
push:
branches:
- main
jobs:
build-push-retrieve:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: 1.20.0
- name: Build
run: CGO_ENABLED=0 go build main.go
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: my-artifact
path: main
retention-days: 5
- name: Remove local binary
run: rm -f ./main
- name: Download a single artifact
uses: actions/download-artifact@v3
with:
name: my-artifact
- name: Execute binary
run: chmod +x ./main && ./main
There’s a way to mock artifacts. We can provide a path in our file system to
store those artifacts in a directory with the following command. Afterward, when
executing the workflow, it’ll create a server that will fetch the file upload
and put it under our ./artifacts/
directory:
mkdir artifacts
act --artifact-server-path ./artifacts/
INFO[0000] Start server on http://10.0.0.189:34567
...
[Artifact demonstration workflow/build-push-retrieve] β Run Main Execute binary
[Artifact demonstration workflow/build-push-retrieve] π³ docker exec cmd=[bash --noprofile --norc -e -o pipefail /var/run/act/workflow/6] user= workdir=
| Hello, world!
[Artifact demonstration workflow/build-push-retrieve] β
Success - Main Execute binary
[Artifact demonstration workflow/build-push-retrieve] β Run Post Set up Go
[Artifact demonstration workflow/build-push-retrieve] π³ docker exec cmd=[node /var/run/act/actions/actions-setup-go@v4/dist/cache-save/index.js] user= workdir=
| [command]/opt/hostedtoolcache/go/1.20.0/x64/bin/go env GOMODCACHE
| [command]/opt/hostedtoolcache/go/1.20.0/x64/bin/go env GOCACHE
| /root/go/pkg/mod
| /root/.cache/go-build
| [warning]Cache folder path is retrieved but doesn't exist on disk: /root/go/pkg/mod
| Primary key was not generated. Please check the log messages above for more errors or information
[Artifact demonstration workflow/build-push-retrieve] β
Success - Post Set up Go
[Artifact demonstration workflow/build-push-retrieve] π Job succeeded
:)