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: