GitHub CI, PHPStan and Docker all together

Aurélien Tournayre
6 min readMay 10, 2023

--

Deploying a CI on GitHub when working in a small structure can be difficult because there is a lack of time and resources to learn and set up.

I was lucky enough to be able to talk to people who advised me and I was able to implement a solution.

Of course, the solution is valid at the time T. The principle remains the same but the implementation may vary depending on the updates of the tools.

Global issues

  • How to reduce CI execution time?
  • Is it really necessary to perform all the steps each time?

What issues are addressed?

  • Build Docker only when needed
  • Run PHPStan and do it fastParallelize the jobs

A little bit of architecture beforehand

The project concerned is organized as follows:

Project structure
CI Tools

Run PHPStan and do it fast

Makefile

We start by creating a Makefile. If you are not familiar with the syntax, don’t worry, me neither, it’s my first!

It is not necessarily necessary, but the solution I was inspired by contained one, so I kept the same logic

_: list

# Config

PHPSTAN_SRC_CONFIG=tools/phpstan/phpstan.neon

# QA

qa: ## Check code quality - coding style and static analysis
make phpstan


phpstan: ## Analyse code with PHPStan
mkdir -p var/tools/PHPStan
$(PRE_PHP) "tools/phpstan/vendor/bin/phpstan" analyse src -c $(PHPSTAN_SRC_CONFIG) $(ARGS)

Job Static Analysis

Currently, there is only PHPStan in this project.

Given the chosen architecture, you can see that 2 composer installs are required.

The cache part speeds up future jobs.

name: CI

on:
pull_request:
push:
branches:
- "develop"
- "main"
env:
php-extensions: "json, sodium, ctype, iconv"
php-tools: "composer:v2"

jobs:
static_analysis:
name: Static analysis
runs-on: "${{ matrix.operating-system }}"
strategy:
matrix:
include:
- operating-system: "ubuntu-20.04"
php-version: "8.2"

steps:
- name: "GIT > Checkout"
uses: "actions/checkout@v3"

- name: "PHP > Install"
uses: "shivammathur/setup-php@v2"
with:
php-version: "${{ matrix.php-version }}"
extensions: "${{ env.php-extensions }}"
tools: "${{ env.php-tools }}"
coverage: "none"

- name: "Composer > Define cache directory"
id: "php-composer-cache"
run: 'echo "::set-output name=dir::$(composer config cache-files-dir)"'

- name: "Composer > Cache dependencies"
uses: "actions/cache@v2"
with:
path: "${{ steps.php-composer-cache.outputs.dir }}"
key: "${{ runner.os }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}"
restore-keys: "${{ runner.os }}-composer-"

- name: "Project > Install PHP dependencies"
run: "composer update --no-interaction --no-progress --prefer-dist --prefer-stable"

- name: "PHPStan > Install PHP dependencies"
run: "composer update --no-interaction --no-progress --prefer-dist --prefer-stable --working-dir=tools/phpstan"

- name: "PHPStan > Cache data"
uses: "actions/cache@v2"
with:
path: "var/tools/PHPStan"
key: "${{ runner.os }}-phpstan"
restore-keys: "${{ runner.os }}-phpstan"

- name: "PHPStan > Run"
run: "make phpstan"

Job Docker Build

When the CI is launched, we want it to be representative of its development environment. So if we have Docker in the development environment, we want this environment to be built during the CI to be ISO.

But in reality, if the build has already passed once and the files haven’t changed, do we really need to build it again? No.

So the idea is to build the Docker environment only when needed. When changing one of the Docker configuration files for example.

name: CI

on:
pull_request:
push:
branches:
- "develop"
- "main"
env:
php-extensions: "json, sodium, ctype, iconv"
php-tools: "composer:v2"

jobs:
docker_build:
name: Docker build
runs-on: "${{ matrix.operating-system }}"
strategy:
matrix:
include:
- operating-system: "ubuntu-20.04"
php-version: "8.2"

steps:
- name: "GIT > Checkout"
uses: "actions/checkout@v3"
with:
fetch-depth: 0

- name: "GIT > Get changed files"
id: changed-files
uses: tj-actions/changed-files@v35

- name: "GIT > List all changed files"
run: |
for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
echo "$file was changed"
done

- name: "GIT > Get changed files in the docker folder"
id: changed-files-specific
uses: tj-actions/changed-files@v35
with:
files: |
docker
docker-compose.override.yml
docker-compose.prod.yml
docker-compose.yml
Dockerfile

- name: "Docker > Pull"
if: steps.changed-files-specific.outputs.any_changed == 'true'
run: docker compose pull

- name: "Docker > UP"
if: steps.changed-files-specific.outputs.any_changed == 'true'
run: docker compose up --build -d

- name: "Docker > Check PHP service"
if: steps.changed-files-specific.outputs.any_changed == 'true'
run: |
while status="$(docker inspect --format="{{if .Config.Healthcheck}}{{print .State.Health.Status}}{{end}}" "$(docker compose ps -q php)")"; do
case $status in
starting) sleep 1;;
healthy) exit 0;;
unhealthy) exit 1;;
esac
done
exit 1

- name: "HTTPS > Check reachability"
if: steps.changed-files-specific.outputs.any_changed == 'true'
run: curl -k https://localhost

Results

Docker build
Static analysis build

Metrics

Before

Total CI time was between 6 and 9 minutes.

After

CI time for PHPStan is around 1–2 minutes.

CI time for Docker is about 6 minutes.

So in normal times, we save 80% of time.

We can therefore merge the features faster, it is also less GitHub billing and you make an ecological gesture by requiring less servers!

Complete code

The Makefile at the beginning.

The complete ci.yaml

name: CI

on:
pull_request:
push:
branches:
- "develop"
- "main"
env:
php-extensions: "json, sodium, ctype, iconv"
php-tools: "composer:v2"

jobs:
docker_build:
name: Docker build
runs-on: "${{ matrix.operating-system }}"
strategy:
matrix:
include:
- operating-system: "ubuntu-20.04"
php-version: "8.2"

steps:
- name: "GIT > Checkout"
uses: "actions/checkout@v3"
with:
fetch-depth: 0

- name: "GIT > Get changed files"
id: changed-files
uses: tj-actions/changed-files@v35

- name: "GIT > List all changed files"
run: |
for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
echo "$file was changed"
done

- name: "GIT > Get changed files in the docker folder"
id: changed-files-specific
uses: tj-actions/changed-files@v35
with:
files: |
docker
docker-compose.override.yml
docker-compose.prod.yml
docker-compose.yml
Dockerfile

- name: "Docker > Pull"
if: steps.changed-files-specific.outputs.any_changed == 'true'
run: docker compose pull

- name: "Docker > UP"
if: steps.changed-files-specific.outputs.any_changed == 'true'
run: docker compose up --build -d

- name: "Docker > Check PHP service"
if: steps.changed-files-specific.outputs.any_changed == 'true'
run: |
while status="$(docker inspect --format="{{if .Config.Healthcheck}}{{print .State.Health.Status}}{{end}}" "$(docker compose ps -q php)")"; do
case $status in
starting) sleep 1;;
healthy) exit 0;;
unhealthy) exit 1;;
esac
done
exit 1

- name: "HTTPS > Check reachability"
if: steps.changed-files-specific.outputs.any_changed == 'true'
run: curl -k https://localhost

static_analysis:
name: Static analysis
runs-on: "${{ matrix.operating-system }}"
strategy:
matrix:
include:
- operating-system: "ubuntu-20.04"
php-version: "8.2"

steps:
- name: "GIT > Checkout"
uses: "actions/checkout@v3"

- name: "PHP > Install"
uses: "shivammathur/setup-php@v2"
with:
php-version: "${{ matrix.php-version }}"
extensions: "${{ env.php-extensions }}"
tools: "${{ env.php-tools }}"
coverage: "none"

- name: "Composer > Define cache directory"
id: "php-composer-cache"
run: 'echo "::set-output name=dir::$(composer config cache-files-dir)"'

- name: "Composer > Cache dependencies"
uses: "actions/cache@v2"
with:
path: "${{ steps.php-composer-cache.outputs.dir }}"
key: "${{ runner.os }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}"
restore-keys: "${{ runner.os }}-composer-"

- name: "Project > Install PHP dependencies"
run: "composer update --no-interaction --no-progress --prefer-dist --prefer-stable"

- name: "PHPStan > Install PHP dependencies"
run: "composer update --no-interaction --no-progress --prefer-dist --prefer-stable --working-dir=tools/phpstan"

- name: "PHPStan > Cache data"
uses: "actions/cache@v2"
with:
path: "var/tools/PHPStan"
key: "${{ runner.os }}-phpstan"
restore-keys: "${{ runner.os }}-phpstan"

- name: "PHPStan > Run"
run: "make phpstan"

Thanks

--

--