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:

  1. Create the following path from the project’s root directory with mkdir -p ./.github/workflows/;
  2. 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:

  1. Provide it through the act CLI flag -s in a key-value manner, like act -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.

  2. Provide an .env file to the CLI option --secret-file. This way, it’s guaranteed that nothing will be issued back to stdout.

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

:)