Running Lando on GitHub Actions

in PHPlast year

At the $dayjob, I am working to have us adopt Lando as a development tool. Lando is a docker-compose abstraction layer that simplifies building standard development environments, such as a bog-standard LAMP stack, and is way easier than raw docker-compose for those cases.

I also wanted to be able to generate test coverage information as part of our Pull Request process. To be clear, test coverage is not the end-all, be-all of good tests, but it is still a useful metric, and can be a useful gate if used properly. Of course, generating test coverage requires running tests; and while most tests should be unit tests that do not require any services, not all are or can be, and many frameworks don't make true unit tests as easy as they should. (cough) So that means building a full dev environment to run tests. There's various tools for that, but I wanted to use GitHub Actions.

I didn't want to deal with docker-compose for GitHub Actions, for all the same reasons I don't want to deal with it for local development. Fortunately, I just went through the process of setting up Lando. Can we just use Lando itself on GitHub Actions?

Turns out, yes we can!

The short version

If you just want the high level "here's the working example" version, I have put the relevant files in a gist where the code highlighting is better. It's reasonably well-commented. This version specifically is intended for Laravel, but it should work with the framework of your choice with only minor modification (mainly the env vars).

I'll walk through it in detail below for those who want to understand the details.

The long version

There are a couple of moving parts involved. Most are reasonable, although not always self-evident (hence this post). In particular:

  • Lando is driven by a single config file, .lando.yml. That file has all the configuration that turns into docker-compose commands under the hood. It should be committed to Git so that all developers are using the same development settings. Lando's own documentation covers it pretty well.
  • Lando also supports a .lando.local.yaml file, which allows you to override settings from .lando.yml. As you'd expect from the name, you're not supposed to commit this to Git, so add it to .gitignore.
  • GitHub Actions are driven by their own YAML file, in .github/workflows. You can have several if you want, but for our purposes we only need one. GitHub's documentation goes into way more detail.
  • And of course there's Composer, which if you're reading this post you are hopefully already familiar with.

Each of these tools has a part to play in our setup.

Configuring Lando

We'll start with Lando itself. There's fairly little you need in .lando.yml that's specific to this use case, but I'll cover the major bits anyway because I like Lando.

# .lando.yml

name: myproject
recipe: laravel
config:
  php: '8.1'
  via: nginx
  database: mysql:5.7
  webroot: public
  xdebug: true

Everything other than name and recipe is optional, but Lando lets you specify various bits about your container cluster. Of note, we're enabling Xdebug for development.

# .lando.yml

services:
  appserver:
    overrides:
      environment:
        # Support debugging CLI with XDEBUG.
        PHP_IDE_CONFIG: "serverName=appserver"
        XDEBUG_TRIGGER: lando
    xdebug: "debug,develop"
    config:
        php: php.ini
    build_as_root:
      # Disable XDebug by default
      - rm -f /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && pkill -o -USR2 php-fpm
      # Install additional necessary packages.
      - pecl install pcov

The services block lets you define new services from templates, or customize existing ones. For now we're just tweaking appserver, where our application runs. There's a couple of important parts here.

  • We're configuring Xdebug for use with our IDE. That's largely off topic for now, but I am including it for completeness.
  • We're setting Xdebug to run in "Debug and Develop" modes, but we're not enabling profiling or code coverage.
  • The build_as_root section is essentially additional Dockerfile RUN lines and allow you to further customize the container. In this example we're disabling Xdebug by default, because it has a performance overhead. We're also installing the PCOV extension, which we'll use for code coverage. It's faster than Xdebug for that purpose, provided you have PHPUnit 8 or later.
  • Installing the extension does not actually enable it. To do that, we need to include an additional php.ini file, which the config block does. That file is trivially simple:
extension=pcov.so

PCOV has other configuration options, but we won't set them here as I'd rather set them in the command itself later. You could set them here if you preferred, however. Of particular note, PCOV will now be installed, but won't actually do anything until its own enabled setting is turned on. (You can put the php.ini file in any subdirectory you want. I chose to put it in the project root but that's a personal preference.)

Finally, there's two extra Lando commands we will define:

# .lando.yml

tooling:
  xdebug-on:
    service: appserver
    description: Enable xdebug for nginx.
    cmd: rm -f /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && docker-php-ext-enable xdebug && pkill -o -USR2 php-fpm && echo "Xdebug enabled"
    user: root

  xdebug-off:
    service: appserver
    description: Disable xdebug for nginx.
    cmd: rm -f /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && pkill -o -USR2 php-fpm && echo "Xdebug disabled"
    user: root

Each tooling entry creates another command that can be run like lando xdebug-on from the command line. The intent here is that while Xdebug is installed and configured, it's only enabled when we say it should be. Given its overhead, that's a good thing. Having it off by default also means that the GitHub Actions Lando environment will not have Xdebug enabled.

That takes care of Lando. It should run locally and give you a fully functional and debuggable development environment.

Huzzah!

Configuring scripts and dependencies

There's two dependent packages we're going to need to install. The first is, naturally, good old phpunit/phpunit. The second is rregeer/phpunit-coverage-check. As its name implies, the latter is a tool to summarize and parse the clover-formatted output from PHPUnit.

Install both as dev-only dependencies:

composer require --dev phpunit/phpunit rregeer/phpunit-coverage-check

Then we set up some Composer scripts. This part is technically optional, but I found it easier than trying to write these commands in the GitHub Action itself. (The escaping was nicer.)

Add the following to your composer.json's scripts block:

{
    "scripts": {
        "test": "phpunit",
        "coverage": "php -dpcov.enabled=1 -dpcov.directory=app vendor/bin/phpunit --coverage-clover build/coverage/clover.xml --coverage-text",
        "coverage-check": "coverage-check build/coverage/clover.xml --only-percentage"
    }
}

The first is self-evident and frankly not necessary, but I'm including it for completeness. The second runs PHPUnit as well, but specifically in "generate coverage" mode. By running php with the -d switches, we can set additional ini configuration values that apply only for this one execution. In this case, we're enabling PCOV and setting its directory to just the app directory. That's where a Laravel app stores all of its meaningful code. If you're using Symfony or some other framework, adjust that directive as appropriate. PCOV's documentation has information on other configuration options. (You could, alternatively, set everything but the enabled flag from the custom php.ini file above. Dealer's choice.)

The coverage command also tells PHPUnit to generate two forms of coverage output. The first is an XML file in "clover" format (side note: I have no idea what that means, it's just what everyone seems to use), which is saved to a file. The second is a text dump to the screen. You don't have to do both, but this allows us to both have a file to analyze later and also see a nice breakdown for humans in the output on GitHub Actions, or locally. You can also skip the text output if you prefer.

Finally, the coverage-check script runs the coverage-check task from the package we installed earlier. All it does is take the clover.xml file generated by the coverage command and crunch the data on it. I have it set to only output a percentage value. If you omit that flag it will give slightly more output, but the overall percentage is all we care about for the moment.

Try running these scripts yourself locally inside your Lando container. They should work perfectly, and that means you can both run tests and get a code coverage analysis with standard configuration at any time, without touching CI.

Huzzah!

Configuring GitHub Actions

So far, all we've done is trick out our Lando configuration. That's good, because everything we've packaged up so far we can run locally, and in a moment will be able to run in GitHub Actions. That means we have only one environment and one set of tooling to have to maintain.

The GitHub Actions script (which I'll put in .github/workflow/testing.yaml) starts off with some basics to always trigger on push or pull requests (which you can customize if you'd like):

name: PHPUnit Tests

on:
  - pull_request
  - push

Then, we'll set some environment variables. These are set into the GitHub Actions environment. Two of them are "ours", meaning they're specific to our script. The others are Laravel environment variables. If you're using not-Laravel, or have additional environment-specific configuration, you'll customize this list accordingly.

env:
  CODE_COVERAGE_MINIMUM: 65
  LANDO_VERSION: v3.6.5

  APP_ENV: testing
  APP_DEBUG: true
  DB_CONNECTION: mysql
  DB_HOST: database
  DB_PORT: 3306
  DB_DATABASE: laravel
  DB_USERNAME: laravel
  DB_PASSWORD: laravel

The DB credentials there are the defaults created by Lando's laravel script. Again, customize as appropriate.

Now we get to the fun bits, where we'll tell GitHub Actions to build a complete Lando environment in which to run tests. This should be pretty much the same for any Lando setup, and that's the point.

We start by declaring a single "job" that will run on a bog-standard Linux environment:

jobs:
  test:
  runs-on: ubuntu-latest
  steps:

The steps block is a YAML array (denoted by - lines) of tasks to run. They can be a single line with a run keyword, or optionally have a name, or optionally reference some pre-packaged action script. We'll go through each one in turn.

- name: Checkout repository
  uses: actions/checkout@v3

You would think that a GitHub Actions script would start with a copy of your code. I certainly thought that. If you thought that, however, you'd be just as wrong as I was. The first thing you need to do is actually check out the code that triggered the action in the first place. Good times.

- name: Grab latest Lando CLI
  run: |
      sudo curl -fsSL -o /usr/local/bin/lando "https://files.lando.dev/cli/lando-linux-x64-${LANDO_VERSION}"
      sudo chmod +x /usr/local/bin/lando

This step downloads the latest Lando directly from Lando itself, as a stand-alone executable. It uses the LANDO_VERSION env var we defined before to control which version to get. As Lando releases new versions, update the env var and you'll get the newer version.

At this point we can optionally also enable the Composer Cache, which saves composer's local cache between runs. I've heard mixed things about how much of a difference it makes, but I'm including it for completeness. It's not actually relevant to the Lando bits.

# This is optional.
- name: Cache Composer packages
  id: composer-cache
  uses: actions/cache@v3
  with:
    path: vendor
    key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
                restore-keys: |
                    ${{ runner.os }}-php-

Now we get to the fun part.

# Map GH Actions env vars into Lando env vars, which wil then be injected
# into the application container.
- run: |
        cat > .lando.local.yml <<EOF
        services:
            appserver:
                overrides:
            environment:
                        APP_ENV: '$APP_ENV'
                        APP_DEBUG: '$APP_DEBUG'
                        DB_CONNECTION: $DB_CONNECTION
                        DB_HOST: $DB_HOST
                        DB_PORT: $DB_PORT
                        DB_DATABASE: $DB_DATABASE
                        DB_USERNAME: $DB_USERNAME
                        DB_PASSWORD: $DB_PASSWORD
        EOF

Be careful of all the indentation here. This is what happens when you embed a YAML string inside a YAML string, and one of the reasons I hate YAML.

We're writing a new file to disk, .lando.local.yml. It contains additional environment variables that will be injected into the PHP environment. All of them are just mapping from the ones we defined at the top of the file. (We could just put them here literally if we wanted to, but I prefer to have anything variable defined up top together.)

Pay special attention to the quotes around some values. That's necessary because of YAML's bonkers rules about when some things need to be quoted to avoid them being interpreted as other values. For example, We want APP_DEBUG: 'true'; but if we just wrote APP_DEBUG: true, then the true would get interpreted and resolve to something other than the literal string true. By quoting the env vars, we ensure that their values are injected literally.

As before, your list of environment variables may vary from those here depending on the framework you're using.

A possibly Laravel-specific warning, though: Be very careful that you do not have a file named .env.$APP_ENV. If you do, it will get read as an alternate .env file by Laravel (technically by the vlucas/phpdotenv package Laravel uses) and may do unexpected things.

Next we have a series of simple steps, so I'll cover them all at once. They're all Lando commands, which means all but the first will run inside the app container, which is what we want.

# Boot Lando and set up Laravel
- run: lando start
- run: lando composer install
- run: lando ssh --command "cp .env.example .env"
- run: lando artisan key:generate
- run: lando artisan migrate
- run: lando artisan db:seed

The first statement boots Lando itself. Lando will build the app container from scratch, download whatever Docker images it needs, etc. How long that step takes will depend on how much work you're doing in your .lando.yml file. We then install Composer dependencies, making sure to run the command inside the container so that they're run with the PHP version we expect. Then, we copy the .env.example file to .env, to account for any default environment variables we haven't already injected.

Side note: One could argue that if they're not environment-specific they shouldn't be in the .env file in the first place. One would be correct. Many frameworks and installations get this wrong. So it goes.

The next three lines are all Laravel setup. Lando includes an artisan command of its own that is really just a shorthand for lando ssh --command "artisan ...", so it's just easier to work with. If you're on Symfony or some other framework the specific commands will be different, but the concept is the same (and there's probably a dedicated shorthand command in the appropriate recipe). The intent is that you have a fully set up and ready-to-go copy of your application by the time you're done.

Finally, we get to the two commands we've been working so hard to get to!

- name: Run tests and generate code coverage report
  run: lando composer coverage

This command runs the coverage composer script we defined earlier in composer.json. Technically this is being double-forwarded, first by Lando into the container, where it runs composer coverage, which Composer then unwraps into the long command we specified before.

Because we're running in a fully booted environment, with a database and all, we can run all our tests this way; clean unit tests, end-to-end tests, integration tests... the works. If any tests fail, this command will return a non-0 exit code and the whole Action will fail, as we want. If they all pass, then a coverage report will be generated in the build directory, and a text summary shown in the GitHub Action console for human review.

Finally, we run one last check, to ensure the test coverage is above the threshold we specified up in the environment variables:

- run: lando composer coverage-check ${CODE_COVERAGE_MINIMUM} || exit 1

The $CODE_COVERAGE_MINIMUM environment variable will be forwarded into the Composer script. That means the coverage-check script will be called with one argument, the desired minimum coverage percentage. That script will return 0 if the coverage is higher than the minimum, and fail if not. I'm not entirely sure why it's error return isn't getting read by GitHub Actions as a proper fail, but the || exit 1 on the end had the desired result. If test coverage is higher than the threshold, it passes. If not, this workflow fails and you get a nice big red X on the Pull Request.

Huzzah!

Conclusion

Whew! OK, that was a long ride, but it's actually pretty straightforward. The key is knowing how to inject the environment variables into the Lando environment on GitHub, and not get tripped up by the .env files. (At least that's where I spent most of my time getting it working. That and realizing that I had to check out the code manually. Silly me.)

The net result is that we now have a full containerized environment that we can run locally for development and on GitHub for CI testing, with all the automation and quality-of-life features that Lando provides. If we need to change it in the future, say to update the PHP version or install additional system packages, we can change it in just one place and it will update both environments. We can also run any kind of test on GitHub, not just unit tests.

The approach here could almost certainly be expanded as well to automate more things. I already have plans to integrate php-cs-fixer into the pipeline as well. Do share your own extensions as well!