Run and debug Github actions locally

 Reading time ~15 minutes

Heads up: this article is over a year old. Some information might be out of date, as I don't always update older articles.

Introduction

The GitHub actions service was launched exactly one year ago (November 2019).

Since then it has got lots of positive feedback and it has quickly convinced many developers to shift the integration and deployment pipelines of their applications from external CI/CD services to GitHub. Having the possibility to build, test and deploy code from a central service greatly helps developers, who are notoriously lazy (or at least, I am ๐Ÿ˜”).

Usually when creating a new GitHub action, you don’t get everything set up correctly the very first time. Just like almost any other part of our jobs, it’s a trial and error process which can take some time to get it right. However the feedback from GitHub is quite slow because you have to commit/push the code and then wait for the action to pick up and run the changes.

For this reason, in this post we will be exploring how GitHub actions can be run and debugged locally.

In order to describe the process we will be covering a workflow focused on testing a Laravel application, triggered by a push event.

The Workflow

A workflow is a series of steps required to complete a CI/CD process. GitHubโ€™s documentation explains them as follows:

Workflows are custom automated processes that you can set up in your repository to build, test, package, release, or deploy any project on Github.

Workflows are written in YAML and they are stored and committed as part of the repository, inside the .github/workflows directory of the project. Once the code gets pushed to GitHub, workflows are detected and processed by the GitHub actions service, based on the instructions that they contain.

The most basic workflow for testing a Laravel application is similar to the following:

name: Laravel CI
on: push

jobs:
  phpunit:
    runs-on: ubuntu-latest
    container:
      image: kirschbaumdevelopment/laravel-test-runner:7.3

    steps:
    - uses: actions/checkout@v2

    - name: Install composer dependencies
      run: composer install --no-scripts

    - name: Copy Laravel environment Laravel Application
      run: cp .env.ci .env

    - name: Generate Key
      run: php artisan key:generate

    - name: Run Testsuite
      run: vendor/bin/phpunit

Let’s quickly break down each part of this file.

name: CI Workflow
on: push

The initial part of the YAML file above defines the name of the workflow and then tells GitHub to run the Action whenever the code gets pushed. Of course you’re not limited to push events, but you can trigger actions in many ways.

Let’s check the next section:

jobs:
  phpunit:
    runs-on: ubuntu-latest
    container:
      image: kirschbaumdevelopment/laravel-test-runner:7.3

    steps:
      - uses: actions/checkout@v2

      - name: Install composer dependencies
        run: composer install --no-scripts

      - name: Copy Laravel environment Laravel Application
        run: cp .env.ci .env

      - name: Generate Key
        run: php artisan key:generate

      - name: Run Testsuite
        run: vendor/bin/phpunit

Here we defined a job called phpunit whose purpose is to run tests using the PHPUnit testing tool on the Laravel application.

  • runs-on identifies the type of machine to run the job on. ubuntu-latest means Ubuntu 18.04, even though Ubuntu 20.04 is alrady available 1
  • container tells GitHub that the actions will run inside a Docker container. We are using the kirschbaumdevelopment/laravel-test-runner:7.3 container, kindly provided by the folks at Kirschbaum, whose enviroment is usually complete enough to cover most testing requirements.

Notice: you can declare more than one job inside the yaml file. For example you might want to add a job to test frontend code using Jest, or perhaps a job that compiles frontend assets, and so on.

It’s important to note that inside a GitHub Action you can use other actions, written and maintained by the community. For example the first step of our phpunit workflow uses the actions/checkout@v2 action, which lets you check-out your repository so the workflow can access it.

You can find a comprehensive list of GitHub actions in this repository. They will definitely make your life easier when building workflows.

Use a database

So far, so good. Now what if we need a database in our tests?

Luckily it’s very easy to set up a database like MySQL in GitHub Actions. You just need add a services key to your yaml file.

Service containers are Docker containers that provide a simple and portable way for you to host services that you might need to test or operate your application in a workflow.

GitHub will create a fresh Docker container for each service configured in the workflow, and will destroy the service container when the job completes. Steps in a job can communicate with all service containers that are part of the same job.

For MySQL we can change the configuration like the following:

jobs:
  phpunit:
    runs-on: ubuntu-latest
    container:
      image: kirschbaumdevelopment/laravel-test-runner:7.3

    services:
      mysql:
        image: mysql:5.7
        env:
          MYSQL_ALLOW_EMPTY_PASSWORD: yes
          MYSQL_USER: username
          MYSQL_PASSWORD: password
          MYSQL_DATABASE: test
        ports:
          - 3306:3306
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3

    steps:
      [...]

PostgreSQL? No problem!

services:
  postgres:
    image: postgres:10.8
    env:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: test
    ports:
      - 5432:5432
    options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5

Test locally

Testing a GitHub Action locally definitely helps when you’re approaching this technology for the first time, and you have to quickly iterate in your trial and error process.

Getting feedback directly from GitHub is slow and can be frustrating. Also if you’re coming from other CI services, such as CircleCI or TravisCI, you might be familiar with their debugging mode 2 3 which basically lets you SSH into the virtual machines or containers in order to troubleshoot any problems. As far as I know, GitHub doesn’t offer this option, so you’re forced to update your configuration locally and then push again.

Act

“Think globally, act locally”

Act is an interesting project, written in Go, that allows you to test GitHub Actions locally.

For obvious reasons it doesn’t provide a full featured replica of the environment provided by GitHub, but it’s definitely powerful enough to cover most of the cases.

After installing it, you can run act -l to see the list of your workflows. If the YAML file is semantically correct you will see the name of the configured job:

$ act -l
 โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
 โ”‚ phpunit โ”‚
 โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

after that you can try to run the workflow by running act.

Act will use the Docker API to either pull or build the necessary images, as defined in your workflow files. It will then use the Docker API to run containers for each action based on the images prepared earlier.

If you’re testing your application without any database or with a SQLite in-memory database you will probably be able to run it successfully the very first time.

$ act
[Laravel CI/phpunit] ๐Ÿš€  Start image=kirschbaumdevelopment/laravel-test-runner:7.3
[Laravel CI/phpunit]   ๐Ÿณ  docker run image=kirschbaumdevelopment/laravel-test-runner:7.3 entrypoint=["/usr/bin/tail" "-f" "/dev/null"] cmd=[]
[Laravel CI/phpunit]   ๐Ÿณ  docker cp src=/home/maurizio/code/laravel-app/. dst=/github/workspace
[Laravel CI/phpunit] โญ  Run actions/checkout@v2
[Laravel CI/phpunit]   โœ…  Success - actions/checkout@v2
[Laravel CI/phpunit] โญ  Run Install composer dependencies
[Laravel CI/phpunit]   โœ…  Success - Install composer dependencies
[Laravel CI/phpunit] โญ  Run Run Testsuite against SQLite
| PHPUnit 8.5.8 by Sebastian Bergmann and contributors.
|
| Runtime:       PHP 7.3.18-1+ubuntu18.04.1+deb.sury.org+1
| Configuration: /github/workspace/phpunit.xml.dist
|
| ...............................................................  63 / 228 ( 27%!)(MISSING)
| ............................................................... 126 / 228 ( 55%!)(MISSING)
| ............................................................... 189 / 228 ( 82%!)(MISSING)
| .......................................                         228 / 228 (100%!)(MISSING)
|
| Time: 7.15 seconds, Memory: 56.50 MB
|
| OK (228 tests, 562 assertions)
[Laravel CI/phpunit]   โœ…  Success - Run Testsuite against SQLite

If instead the action fails, you can SSH into the Docker container and debug it from there.

First let’s list the active containers:

$ docker ps
CONTAINER ID        IMAGE                                           COMMAND                  CREATED              STATUS              PORTS               NAMES
b7649a8ef09e        kirschbaumdevelopment/laravel-test-runner:7.3   "/usr/bin/tail -f /dโ€ฆ"   About a minute ago   Up About a minute                       act-Laravel-CI-phpunit

then after checking the Container ID, you can run docker exec -it b7649 bash to enter the container. From there it’s way easier to spot any configuration errors.

Adding a Database

Adding a real database layer to the tests gradually increases the complexity, which is actually inherently related to the management of environment variables.

Act does not fully support defined services, or at least it has some issues. I noticed that using the nektos/act-environments-ubuntu:18.04 image I was not able to use MySQL, which is a known issue. However I was able to overcome this problem by spinning up the MySQL container myself.

For example when you run an action that uses MySQL on GitHub you will see something like this, under the Initialize containers section

/usr/bin/docker create
  --name a864a8672f73421e867ed5b93bd8b9e4_mysql57_1e0ef3
  --label 1e5c35
  --network github_network_92a3d72e5a104bd7abfb9e31dfaa7e51
  --network-alias mysql
  -p 3306:3306
  --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
  -e "MYSQL_ALLOW_EMPTY_PASSWORD=true"
  -e "MYSQL_DATABASE=test"
  -e "MYSQL_USER=username"
  -e "MYSQL_PASSWORD=password"
  -e GITHUB_ACTIONS=true
  -e CI=true
  mysql:5.7

we can use this command as a reference to spin up a MySQL Docker container on our local machine to use it as database for our tests.

If you read carefully you will notice that the environment variables used in the previous command are the same variables defined in the env section of the YAML file.

We can create the container using a command like the following

$ docker create
  --name mysql57
  -p 3306:3306
  --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
  -e "MYSQL_ALLOW_EMPTY_PASSWORD=true"
  -e "MYSQL_DATABASE=test"
  -e "MYSQL_USER=username"
  -e "MYSQL_PASSWORD=password"
  mysql:5.7

next we can start it, using the hash value returned by the previous command

$ docker start e7d5021c91d932447870eb56680153f49758ce0492ac2559b5754e7a38f3765

finally we can inspect the container to retrieve the low-level information on the Docker object, including the assigned IP address

$ docker inspect mysql57

by default, the command will render all results in a JSON array. You can use the following to grab only the IP address:

$ docker inspect --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' mysql57

That same IP address will be used as the value for the DB_HOST environment variable.

The final configuration file will be:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
on: push
name: Laravel CI
jobs:
  phpunit:
    runs-on: ubuntu-latest
    container:
      image: kirschbaumdevelopment/laravel-test-runner:7.3

    services:
      mysql:
        image: mysql:5.7
        env:
          MYSQL_ALLOW_EMPTY_PASSWORD: yes
          MYSQL_USER: username
          MYSQL_PASSWORD: password
          MYSQL_DATABASE: test
        ports:
          - 3306:3306
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3

    steps:
      - uses: actions/checkout@v2

      - name: Install composer dependencies
        run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist

      - name: Copy Laravel CI environment
        run: cp .env.ci .env

      - name: Generate Key
        run: php artisan key:generate

      - name: Migrate the Database
        run: php artisan migrate
        env:
          DB_HOST: 172.17.0.2

      - name: Run Testsuite
        run: vendor/bin/phpunit
        env:
          DB_HOST: 172.17.0.2

As you can see on line 28 we’re using a .env.ci file that only contains the smallest amout of environment variables required to run the Action. It’s helpful so you don’t have to write them directly multiple times inside the YAML file.

Notice: use the env key in the YAML only when you specifically have to override the values defined in the .env.ci file. Also make sure that the same variables aren’t also defined in the phpunit.xml file.

# .env.ci
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_USERNAME=username
DB_PASSWORD=password
DB_DATABASE=test

You might be wondering why in this file the DB_HOST variable contains mysql, instead of an IP address. This is not an error.

On GitHub you can configure jobs in a workflow to run directly on a runner machine or in a Docker container (like we are doing in this example). Communication between a job and its service containers is different depending on whether a job runs directly on the runner machine or in a container.

When you run jobs in a container, GitHub connects service containers to the job using Docker’s user-defined bridge networks.

Running the job and services in a container simplifies network access. For this reason you can access a service container using the label you configured in the workflow, which in this case is mysql. This value will be mapped to the hostname of the service container.

To test things locally however it is just easier to inspect the container and use the IP address, rather than setting up a network. Unless of course you have to deal with multiple services that need to talk to each other.

PostgreSQL

Running PostgreSQL is as easy as it gets.

$ docker create
  --name postgres11
  --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
  -p 5432:5432
  -e POSTGRES_USER=homestead
  -e POSTGRES_PASSWORD=secret
  -e POSTGRES_DB=mydatabase
  postgres:11

The process is the same: inspect the container, retrieve the IP Address and update the env key in the YAML file.

In the end the final YAML file will look like this. PHPUnit tests will run on both MySQL and PostgreSQL.

on: push
name: Laravel CI
jobs:
  phpunit:
    runs-on: ubuntu-latest
    container:
      image: kirschbaumdevelopment/laravel-test-runner:7.3

    services:
      mysql:
        image: mysql:5.7
        env:
          MYSQL_ALLOW_EMPTY_PASSWORD: yes
          MYSQL_USER: username
          MYSQL_PASSWORD: password
          MYSQL_DATABASE: test
        ports:
          - 3306
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3

      postgres:
        image: postgres:11
        env:
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432
        options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3

    steps:
      - uses: actions/checkout@v2

      - name: Install composer dependencies
        run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist

      - name: Copy Laravel CI environment
        run: cp .env.ci .env

      - name: Generate Key
        run: php artisan key:generate

      - name: Migrate database
        run: php artisan migrate

      - name: Run Testsuite against MySQL
        run: vendor/bin/phpunit
        env:
          DB_CONNECTION: mysql
          DB_HOST: 172.17.0.2
      - name: Run Testsuite against Postgres
        run: vendor/bin/phpunit
        env:
          DB_CONNECTION: pgsql
          DB_HOST: 172.17.0.3

Finally we can run act and check if the workflows passes.

Warning: Before running the GitHub action locally make sure that you working directory it’s in a clean status. In fact act copies the repository fro the local directory, instead of downloading from GitHub.

$ act
[Laravel CI/phpunit] ๐Ÿš€  Start image=kirschbaumdevelopment/laravel-test-runner:7.3
[Laravel CI/phpunit]   ๐Ÿณ  docker run image=kirschbaumdevelopment/laravel-test-runner:7.3 entrypoint=["/usr/bin/tail" "-f" "/dev/null"] cmd=[]
[Laravel CI/phpunit]   ๐Ÿณ  docker cp src=/home/maurizio/code/laravel-app/. dst=/github/workspace
[Laravel CI/phpunit] โญ  Run actions/checkout@v2
[Laravel CI/phpunit]   โœ…  Success - actions/checkout@v2
[Laravel CI/phpunit] โญ  Run Install composer dependencies
[Laravel CI/phpunit]   โœ…  Success - Install composer dependencies
[Laravel CI/phpunit] โญ  Run Migrate database
[Laravel CI/phpunit]   โœ…  Success - Migrate database
[Laravel CI/phpunit] โญ  Run Run Testsuite against MySQL
| PHPUnit 8.5.5 by Sebastian Bergmann and contributors.
|
| Runtime:       PHP 7.3.18-1+ubuntu18.04.1+deb.sury.org+1
| Configuration: /github/workspace/phpunit.xml.dist
|
| ...............................................................  63 / 157 ( 40%!)(MISSING)
| ............................................................... 126 / 157 ( 80%!)(MISSING)
| ...............................                                 157 / 157 (100%!)(MISSING)
|
| Time: 4.37 seconds, Memory: 48.50 MB
|
| OK (157 tests, 350 assertions)
[Laravel CI/phpunit]   โœ…  Success - Run Testsuite against MySQL
[Laravel CI/phpunit] โญ  Run Run Testsuite against Postgres
| PHPUnit 8.5.5 by Sebastian Bergmann and contributors.
|
| Runtime:       PHP 7.3.18-1+ubuntu18.04.1+deb.sury.org+1
| Configuration: /github/workspace/phpunit.xml.dist
|
| ...............................................................  63 / 157 ( 40%!)(MISSING)
| ............................................................... 126 / 157 ( 80%!)(MISSING)
| ...............................                                 157 / 157 (100%!)(MISSING)
|
| Time: 4.72 seconds, Memory: 48.50 MB
|
| OK (157 tests, 350 assertions)
[Laravel CI/phpunit]   โœ…  Success - Run Testsuite against Postgres

Potential issues

  • If you get this error Doctrine\DBAL\Driver\PDOException: SQLSTATE[HY000] [2002] Connection refused make sure that you have the database port configured correctly in the database.php config file. I noticed that in some old Laravel versions it’s not present.

Running a workflow from the runner machine

Running a workflow from the runner machine is not that different, you just have to pay more attention to the network configuration.

Service containers can be accessed using localhost:<port> or 127.0.0.1:<port>. GitHub configures the container network to enable communication from the service container to the Docker host. However the service running in the Docker container does not expose its ports to the job on the runner by default. You need to map ports on the service container to the Docker host.

Let’s see how it can be done by examining the following YAML file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
on: push
name: Laravel CI

jobs:
  phpunit:
    runs-on: ubuntu-latest

    services:
      mysql:
        image: mysql:5.7
        env:
          MYSQL_ALLOW_EMPTY_PASSWORD: yes
          MYSQL_USER: username
          MYSQL_PASSWORD: password
          MYSQL_DATABASE: test
        ports:
          - 3306
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3

    steps:
      - uses: actions/checkout@v2
        with:
          fetch-depth: 1

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '7.3'
          tools: phpunit, prestissimo
          extensions: gd, json, mbstring, mysql, dom, fileinfo, xml, xsl, zip, curl, bcmath, intl
          coverage: none

      - name: Get composer cache directory
        id: composer-cache
        run: echo "::set-output name=dir::$(composer config cache-files-dir)"

      - name: Cache composer dependencies
        uses: actions/cache@v1
        with:
          path: ${{ steps.composer-cache.outputs.dir }}
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-composer-

      - name: Install Composer dependencies
        run: composer install --no-scripts --no-progress --no-suggest --prefer-dist --optimize-autoloader

      - name: Copy Laravel CI environment
        run: cp .env.ci .env

      - name: Generate Key
        run: php artisan key:generate

      - name: Run Migration
        run: php artisan migrate
        env:
          DB_PORT: ${{ job.services.mysql.ports['3306'] }}

      - name: Run Testsuite
        run: vendor/bin/phpunit
        env:
          DB_PORT: ${{ job.services.mysql.ports['3306'] }}

In this workflow we’re not using a container image. Instead we’re using another action provided by the community. In this case the Setup PHP Action is used to setup PHP with the required extensions.

We’re also using GitHub caching to cache dependencies and speed up the workflow. The composer dependencies will be downloaded and stored in a local cache, so that they will already be available for the next runs. The key used to identify the cache depends on the hashed value of the composer.lock, therefore if your dependencies change you will get freshly downloaded packages.

Finally you can see that on line 55 we’re using interpolation ${{ job.services.mysql.ports['3306'] }} to retrieve the port of the MySQL service from the context. In fact, unless you explicitly map the Docker host port to the service container port, the container port will randomly be assigned to a free port.

If running act with this configuration you get this error act ::error::ENOENT: no such file or directory, open '/opt/hostedtoolcache/linux.sh', you can solve it by running act -P ubuntu-latest=shivammathur/node:latest.

Conclusions

That’s all there is to it. I hope you find this post helpful!

Update: see also this interesting article https://dev.to/patinthehat/testing-laravel-packages-locally-with-docker-3gm5

Thanks Will for proof reading this post ๐Ÿ˜‰

comments powered by Disqus

Reduce the size of a large MySQL dump file

Introduction

I already mentioned in a previous post that sometimes at work we have to deal with very large mysqldump backups โ€ฆ