Speed up GitHub Actions by caching Composer, Rector, & Pint

Speed up GitHub Actions by caching Composer, Rector, & Pint

·

5 min read

GitHub's 2000 minutes per month of free usage of GitHub Actions is usually enough to handle light workflows. However, as more tech is run automatically, we are forced to upgrade to a paid plan or optimize our workflows.

Currently, I am running Rector, Pint, and PHPUnit tests on every PR. If Rector or Pint discover something that can be improved on the PR they're configured to make another commit. That triggers another run of the pipeline.

In a modest codebase, the three of them together added up to an average of 15 minutes per execution, which is painfully a lot. By using the GitHub Actions caching mechanism we managed to bring it down to an average of 4.5 minutes. The full code and some useful links are at the bottom, here are the steps.

Caching Composer vendor

This is part of the job definition where we cache the vendor folder:

jobs:
  code-quality:
    runs-on: ubuntu-latest
    steps:
      - uses: shivammathur/setup-php@v2
        with:
          php-version: 8.1
      - uses: actions/checkout@v3
      - name: Cache Vendor
        id: cache-vendor
        uses: actions/cache@v3
        with:
          path: vendor
          key: ${{ runner.os }}-vendor-${{ hashFiles('**/composer.lock') }}
      - name: "Install Dependencies"
        if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
        run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist

It's not much of an improvement, as Composer installs are usually fast, but you shelve off about 6-8 seconds out of every run. Caching the other stuff is much more impactful.

Caching Rector

Rector is run in parallel by default, and it's usually really fast locally. However, GitHub-hosted runners have only 2 cores, so Rector is a bit slow. On average, Rector took about 5 minutes to run, before caching.

The first thing we do is enable file-based caching in our rector.php configuration.

<?php

declare(strict_types=1);

use Rector\Caching\ValueObject\Storage\FileCacheStorage;
use Rector\Config\RectorConfig;

return static function (RectorConfig $rectorConfig): void {
    $rectorConfig->paths([
        __DIR__.'/app',
        __DIR__.'/tests',
    ]);

    $rectorConfig->importNames();

    $rectorConfig->sets([...]);

    // Ensure file system caching is used instead of in-memory.
    $rectorConfig->cacheClass(FileCacheStorage::class);

    // Specify a path that works locally as well as on CI job runners.
    $rectorConfig->cacheDirectory('./storage/rector/cache');
};

This will also speed up your subsequent local Rector runs, as it will only check the modified files instead of the whole codebase.

Next up, in GitHub Actions we do the same caching as before, but instead of caching the vendor directory, we cache the storage/rector/cache directory, or wherever you stored your Rector cache.

jobs:
  code-quality:
    runs-on: ubuntu-latest
    steps:
      - ...
      - name: Cache Rector
        uses: actions/cache@v3
        with:
          path: ./storage/rector/cache
          key: ${{ runner.os }}-rector-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-rector-
      - name: Run Rector
        run: vendor/bin/rector --ansi

The first time the pipeline is triggered it will take the full amount of time to run. However, it will store the Rector cache and use it in subsequent runs. Depending on the number of files affected in your PRs, the Rector step now only takes a few seconds.

Caching Pint

I struggled with this a bit as Pint is built on top of PHP CS Fixer and there was no clear way on how to configure caching. I found out that PHP CS Fixer is using a cache by default, and you can specify the cache file location by providing an option when running the fixer command. However, Pint did not pass the --cache-file option to the PHP CS Fixer.

Instead, you can configure the Pint cache location in your pint.json:

{
    "preset": "laravel",
    "exclude":[...],
    "cache-file": "storage/pint.cache",
    "rules": {...}
}

You can choose whatever location fits you best. Now that we have a fixed cache location, we do the same code for GitHub Actions:

jobs:
  code-quality:
    runs-on: ubuntu-latest
    steps:
      - ...
      - name: Cache Pint
        uses: actions/cache@v3
        with:
          path: ./storage/pint.cache
          key: ${{ runner.os }}-pint-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-pint-
      - name: Run Pint
        run: ./vendor/bin/pint

This brings Pint execution down to just a few seconds as well, depending on the size of your PRs.

The complete pipeline

Here's the full code of a Laravel project pipeline that runs Rector and Pint, and does a commit to your PR with refactoring or styling changes, if needed. When the Rector and Pint job finishes, the PHPUnit tests start to run.

name: Rector, Pint & Tests

on:
  pull_request:
    branches: [ master ]

jobs:
  code-quality:
    runs-on: ubuntu-latest
    steps:
      - uses: shivammathur/setup-php@v2
        with:
          php-version: 8.1
      - uses: actions/checkout@v3
        with:
          # Must be used to trigger workflow after push
          token: ${{ secrets.ACCESS_TOKEN }}
      - name: Cache Vendor
        id: cache-vendor
        uses: actions/cache@v3
        with:
          path: vendor
          key: ${{ runner.os }}-vendor-${{ hashFiles('**/composer.lock') }}
      - name: "Install Dependencies"
        if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
        run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
      - name: Cache Rector
        uses: actions/cache@v3
        with:
          path: ./storage/rector/cache
          key: ${{ runner.os }}-rector-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-rector-
      - name: "Run Rector"
        run: vendor/bin/rector --ansi
      - name: Cache Pint
        uses: actions/cache@v3
        with:
          path: ./storage/pint.cache
          key: ${{ runner.os }}-pint-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-pint-
      - name: "Run Pint"
        run: ./vendor/bin/pint
      - uses: stefanzweifel/git-auto-commit-action@v4
        with:
          commit_message: '[ci-review] Rector & Pint'
          commit_author: 'GitHub Action <actions@github.com>'
          commit_user_email: 'action@github.com'

  tests:
    runs-on: ubuntu-latest
    needs: code-quality
    services:
      mysql:
        image: mysql:8.0.25
        env:
          MYSQL_ALLOW_EMPTY_PASSWORD: false
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: my_app_test
        ports:
          - 3306/tcp
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
    steps:
      - uses: shivammathur/setup-php@2.23.0
        with:
          php-version: '8.1'
          extensions: mbstring, dom, fileinfo, mysql
      - uses: actions/checkout@v3
        with:
          # Must be used to trigger workflow after push
          token: ${{ secrets.ACCESS_TOKEN }}
      - name: Copy .env
        run: php -r "file_exists('.env') || copy('.env.example', '.env');"
      - name: Cache Vendor
        id: cache-vendor
        uses: actions/cache@v3
        with:
          path: vendor
          key: ${{ runner.os }}-vendor-${{ hashFiles('**/composer.lock') }}
      - name: Install Dependencies
        if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
        run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
      - name: Generate key
        run: php artisan key:generate
      - name: PHPUnit
        run: php artisan test --parallel
        env:
          DB_TEST_PORT: ${{ job.services.mysql.ports['3306'] }}

Check out these resources for more: