Le « petit fix rapide » du dimanche soir
21h47, dimanche. Tu règles un bug d'affichage sur la page boutique, rien de méchant. git push. Pas besoin de lancer les tests, tu n'as touché qu'un composant isolé. Tu fermes l'ordi.
Lundi matin, ton collègue ouvre le tableau de bord : la fonctionnalité de calcul de remise Symfony est cassée depuis 14 heures. Ton correctif avait importé une constante au mauvais endroit, et le coupon de réduction appliquait × 0.10 au lieu de × -0.10. Les clients payaient plus cher.
Le problème n'est pas que tu as oublié de tester. Tout le monde oublie. Le problème, c'est que rien ne vérifiait à ta place. La CI, ou continuous integration (intégration continue), c'est un robot qui rejoue tes tests à chaque push, sans exception et sans fatigue. Ton oubli du dimanche soir ? Il aurait été intercepté en deux minutes, avant que quiconque ne merge, avant que quiconque ne déploie.
Jusqu'ici, on a tout fait sur le serveur : tu l'as durci, tu as appris à systemd à veiller sur ton app, Caddy lui a posé un HTTPS automatique, et la leçon 4 a mis ton app dans un conteneur Docker. Le serveur est prêt. À partir de maintenant, on change de terrain : on automatise tout ce qui se passe avant la mise en ligne, depuis GitHub. Et ça commence par les tests.
Définition : l'intégration continue consiste à déclencher automatiquement un ensemble de vérifications (build, tests, lint...) à chaque modification du code, sur une machine neutre. L'objectif : détecter les régressions au moment du commit, pas au moment du déploiement. Et certainement pas le lundi matin.
GitHub Actions en clair
GitHub Actions est le moteur de CI intégré à GitHub. Pas de serveur à configurer, pas de plugin à installer : tu déposes un fichier YAML dans ton dépôt, et GitHub fait le reste.
Voilà à quoi ressemble un fichier .github/workflows/ci.yml pour un projet Symfony :
name: CI
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- run: composer install --prefer-dist --no-progress
- run: composer audit # bloque si une dépendance a une faille connue
- run: php bin/phpunit # tes tests (cours Tester son code !)
Ligne à ligne :
on: push: branches: [main]: le déclencheur. GitHub surveille les pushs surmainet lance le workflow dès qu'il en détecte un. Tu peux lister d'autres branches ou ajouterpull_request.runs-on: ubuntu-latest: le runner est une machine Ubuntu fraîche, allouée par GitHub, détruite après le run. Aucun état résiduel.actions/checkout@v4: clone ton dépôt sur cette machine fraîche.shivammathur/setup-php@v2: installe PHP 8.3 sur le runner.composer install --prefer-dist --no-progress: installe les dépendances depuis lecomposer.lock. Composer respecte le lockfile à la lettre, exactement commenpm cirespectepackage-lock.json: reproductible, sans surprise.composer audit: vérifie chaque dépendance contre la base de vulnérabilités connues. Si une faille est détectée, le step échoue et bloque tout. Ta CI surveille aussi la supply chain.php bin/phpunit: lance ta suite de tests. Si un test échoue, le step retourne un code non nul, le job s'arrête en rouge, et le commit est marqué ✗.
La machine fraîche, un bonus inattendu : le runner GitHub est une Ubuntu vierge à chaque run. Si ta CI passe au vert, ça prouve aussi que ta procédure d'installation est complète, sans aucune dépendance cachée sur ta machine locale. Si ça échoue sur le runner mais pas chez toi, c'est un oubli dans ton composer.json ou ton README.
Et pour une app Node ou Go ? La structure est identique. Pour Node : remplace setup-php par actions/setup-node@v4, composer install par npm ci, et php bin/phpunit par npm test. Pour Go : pas d'action spéciale, Go est déjà installé sur le runner ; tu fais juste go test ./.... C'est la beauté du modèle : le job de test change d'outil, mais le reste du workflow, y compris le job de construction d'image qui suit, est exactement le même.
Lire le résultat : va dans l'onglet Actions de ton dépôt GitHub. Chaque run affiche les steps avec leur durée, un ✓ vert ou un ✗ rouge, et les logs complets. Le commit lui-même reçoit une pastille colorée dans l'historique.
Le job build (qu'on voit juste après) a needs: test. Ton phpunit échoue sur un test de remise. Que se passe-t-il pour la construction de l'image Docker ?
Voir la réponse
L'image n'est jamais construite. needs: test rend le job build dépendant du succès du job test. Si phpunit échoue, le job test est rouge, et GitHub ne démarre même pas le job build. C'est le rôle du gardien : pas de vert, pas d'image. Le VPS ne verra jamais une image construite depuis du code cassé.
La CI construit aussi ton image
Tester, c'est bien. Mais tant qu'on fait composer install directement sur le VPS au moment du déploiement, on a encore un problème : le VPS doit avoir internet, PHP, Composer, et il peut tomber au milieu d'une install. Ce n'est pas une mise en production, c'est du bricolage.
L'approche propre : la CI fabrique une image Docker finie, avec l'app déjà installée et compilée, et la range dans un registre. Le VPS, lui, ne fait que docker pull. Rien à compiler en prod, aucune surprise. C'est exactement ce qu'on a préparé en leçon 4 : l'image ghcr.io/toi/ton-projet.
Ce registre, c'est GHCR (GitHub Container Registry). C'est le « garage à images » de GitHub : gratuit pour tes projets, intégré à ton compte, accessible depuis ton VPS.
Voici le job build à ajouter dans le même fichier ci.yml, après le job test :
build:
needs: test # pas de vert, pas d'image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # le droit de pousser sur GHCR
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # fourni par GitHub, rien à créer
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/toi/ton-projet:main
ghcr.io/toi/ton-projet:sha-${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
Ligne à ligne :
needs: test: le gardien. Ce job ne démarre que si le jobtests'est terminé avec succès. Aucune image ne sort d'un code en échec.permissions: packages: write: déclare explicitement que ce job a le droit de pousser sur GHCR. Sans ça, GitHub refuse.docker/login-action@v3: connecte le runner àghcr.io. Le mot de passe, c'estGITHUB_TOKEN.secrets.GITHUB_TOKEN: GitHub crée ce token automatiquement pour chaque run. Tu n'as rien à créer dans les réglages du dépôt, contrairement à la clé SSH qu'on verra en leçon 7. Il dure le temps du run et rien d'autre.docker/build-push-action@v6: construit l'image depuis leDockerfileà la racine du projet (context: .), puis la pousse.- Le double tag :
:mainest « la dernière bonne version ». Le VPS la suit en leçon 7 : quand il faitdocker pull ghcr.io/toi/ton-projet:main, il obtient toujours le dernier vert.:sha-...est une photo immuable de ce commit précis. Elle ne bougera jamais. C'est sur elle que vivra le rollback de la leçon 8 : revenir à un SHA précis si la dernière version pose problème. cache-from / cache-to: type=gha: le cache GitHub Actions. Docker réutilise les couches déjà construites d'un run à l'autre. Un changement d'une seule ligne de code ne reconstruit pas tout depuis zéro.
Protège la branche main (branch protection) : dans les réglages du dépôt GitHub, tu peux exiger qu'un CI passe avant tout merge de Pull Request. Résultat : il devient impossible de merger du code rouge sur main, même pour toi, même en oubliant. C'est la règle d'or des équipes sérieuses : le rouge bloque, sans exception.
La recette de l'image : le Dockerfile
La CI construit l'image depuis le Dockerfile à la racine du projet. C'est ce fichier qui décrit, couche par couche, ce que contient l'image finale.
Pour un projet Symfony avec FrankenPHP, ce Dockerfile est multi-stage : trois étages qui se passent le travail.
- Étage 1 :
base. On part de l'image officielle FrankenPHP avec PHP 8.3. On installe les extensions PHP nécessaires (intl, opcache, zip...). Cette base est réutilisée par les deux étages suivants. - Étage 2 :
builder. On copiecomposer.jsonetcomposer.lock, on faitcomposer install --no-dev(les dépendances de dev ne vont pas en prod), puis on copie le reste du code. C'est ici que l'app est « construite ». - Étage 3 :
prod. On repart debase(propre, sans les outils de build) et on copie uniquement l'app compilée depuisbuilder. L'image finale est légère : pas de Composer, pas d'outils inutiles.
Tu n'as pas à écrire ce Dockerfile de zéro. Le template officiel dunglas/symfony-docker le fournit. Tu l'adoptes et tu le lis. Voici un extrait représentatif :
FROM dunglas/frankenphp:1-php8.3 AS base
RUN install-php-extensions @composer intl opcache zip
FROM base AS builder
COPY composer.* ./
RUN composer install --no-dev --prefer-dist --no-scripts
COPY . .
RUN composer dump-autoload --optimize
FROM base AS prod
COPY --from=builder /app /app
Ce que ça donne en pratique : l'image finale n'embarque pas Composer, pas les sources de build des assets, pas les dépendances de développement. Elle est reproductible, légère, et construite une seule fois par la CI. Le VPS ne fait que la télécharger.
:main sur le VPS.Tes tests passent tous, mais composer audit trouve une CVE dans une dépendance. Que devient le job build, et que dois-tu faire pour déployer à nouveau ?
Voir la réponse
Le job build ne démarre pas. composer audit fait partie du job test : s'il échoue, le job est rouge, et needs: test bloque la construction de l'image. Une faille connue est traitée exactement comme un test cassé : rien ne sort. Pour redéployer, tu mets à jour la dépendance vulnérable (composer update vendor/paquet), tu pousses, et la chaîne repart au vert.
À toi : la CI en action
Un terminal simulé, branché sur un projet Symfony avec GitHub Actions configuré. Tu vas pousser un commit, voir la CI échouer en rouge (le job build ne démarre même pas), corriger, et voir l'image construite et poussée sur GHCR.
The "quick little fix" on Sunday night
9:47 p.m., Sunday. You fix a display bug on the shop page, nothing serious. git push. No need to run the tests — you only touched an isolated component. You close the laptop.
Monday morning, your colleague opens the dashboard: the Symfony discount calculation feature has been broken for 14 hours. Your fix had imported a constant in the wrong place, and the coupon was applying × 0.10 instead of × -0.10. Customers were paying more.
The problem isn't that you forgot to test. Everyone forgets. The problem is that nothing was checking on your behalf. CI (continuous integration) is a robot that replays your tests on every push, no exceptions, no fatigue. That Sunday-night slip? Caught in two minutes, before anyone merged, before anyone deployed.
So far, everything happened on the server: you hardened it, you taught systemd to watch over your app, Caddy gave it automatic HTTPS, and lesson 4 put your app inside a Docker container. The server is ready. From now on, the ground shifts: we automate everything that happens before going live, from GitHub. And it starts with the tests.
Definition: continuous integration means automatically triggering a set of checks (build, tests, lint...) on every code change, on a neutral machine. The goal: catch regressions at commit time, not at deploy time, and certainly not on Monday morning.
GitHub Actions in plain English
GitHub Actions is GitHub's built-in CI engine. No server to configure, no plugin to install: you drop a YAML file in your repo, and GitHub does the rest.
Here's what a .github/workflows/ci.yml file looks like for a Symfony project:
name: CI
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- run: composer install --prefer-dist --no-progress
- run: composer audit # blocks if a dependency has a known vulnerability
- run: php bin/phpunit # your tests (see the Testing course!)
Line by line:
on: push: branches: [main]— the trigger. GitHub watches for pushes tomainand starts the workflow as soon as it sees one. You can list other branches or addpull_request.runs-on: ubuntu-latest— the runner is a fresh Ubuntu machine, allocated by GitHub, destroyed after the run. No residual state.actions/checkout@v4— clones your repo onto that fresh machine.shivammathur/setup-php@v2— installs PHP 8.3 on the runner.composer install --prefer-dist --no-progress— installs dependencies fromcomposer.lock. Composer honours the lockfile to the letter, just asnpm cihonourspackage-lock.json: reproducible, no surprises.composer audit— checks every dependency against the known vulnerability database. If a flaw is found, the step fails and blocks everything. Your CI also watches the supply chain.php bin/phpunit— runs your test suite. If a test fails, the step returns a non-zero code, the job stops in red, and the commit is marked ✗.
The fresh machine — an unexpected bonus: the GitHub runner is a clean Ubuntu on every run. If your CI goes green, it also proves that your install procedure is complete — no hidden dependency on your local machine. If it fails on the runner but not on your machine, you have a gap in your composer.json or your README.
And for a Node or Go app? The structure is identical. For Node: replace setup-php with actions/setup-node@v4, composer install with npm ci, and php bin/phpunit with npm test. For Go: no special action needed, Go is already on the runner; just run go test ./.... That's the beauty of the model: the test job swaps its tool, but the rest of the workflow — including the image-building job below — is exactly the same.
Reading the result: go to the Actions tab of your GitHub repo. Each run shows the steps with their duration, a green ✓ or a red ✗, and the full logs. The commit itself gets a coloured dot in the history.
The build job (coming right after) has needs: test. Your phpunit fails on a discount test. What happens to the Docker image build?
Show the answer
The image is never built. needs: test makes the build job depend on the test job succeeding. If phpunit fails, the test job is red, and GitHub never even starts the build job. That's the guardian's role: no green, no image. The VPS will never see an image built from broken code.
CI also builds your image
Testing is great. But as long as you run composer install directly on the VPS at deploy time, you still have a problem: the VPS needs internet access, PHP, Composer — and it can crash mid-install. That's not a production deployment, it's patchwork.
The clean approach: CI builds a Docker image that's already finished — app installed and compiled — and stores it in a registry. The VPS just does docker pull. Nothing to compile in production, no surprises. That's exactly what lesson 4 prepared: the ghcr.io/you/your-project image.
That registry is GHCR (GitHub Container Registry) — GitHub's image garage: free for your projects, built into your account, reachable from your VPS.
Here's the build job to add in the same ci.yml file, after the test job:
build:
needs: test # no green, no image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # right to push to GHCR
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # provided by GitHub, nothing to create
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/you/your-project:main
ghcr.io/you/your-project:sha-${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
Line by line:
needs: test— the guardian. This job only starts if thetestjob succeeded. No image ever comes out of broken code.permissions: packages: write— explicitly tells GitHub this job may push to GHCR. Without it, GitHub refuses.docker/login-action@v3— connects the runner toghcr.io. The password isGITHUB_TOKEN.secrets.GITHUB_TOKEN— GitHub creates this token automatically for every run. You don't create it in the repo settings — unlike the SSH key in lesson 7. It lasts the run and nothing more.docker/build-push-action@v6— builds the image from theDockerfileat the repo root (context: .), then pushes it.- The double tag:
:mainis "the latest good version". The VPS follows it in lesson 7: when it runsdocker pull ghcr.io/you/your-project:main, it always gets the latest green.:sha-...is an immutable snapshot of this exact commit. It will never change. That's what lesson 8's rollback will use: return to a precise SHA if the latest version causes trouble. cache-from / cache-to: type=gha— the GitHub Actions cache. Docker reuses already-built layers from run to run. Changing a single line of code doesn't rebuild everything from scratch.
Protect the main branch (branch protection): in your GitHub repo settings, you can require a passing CI before any Pull Request can be merged. Result: it becomes impossible to merge red code into main — even for you, even by forgetting. This is the golden rule of serious teams: red blocks, no exception.
The image recipe: the Dockerfile
CI builds the image from the Dockerfile at the repo root. That file describes, layer by layer, what the final image contains.
For a Symfony project with FrankenPHP, this Dockerfile is multi-stage: three stages passing work to each other.
- Stage 1:
base. Start from the official FrankenPHP image with PHP 8.3. Install the required PHP extensions (intl, opcache, zip...). This base is reused by the two stages below. - Stage 2:
builder. Copycomposer.jsonandcomposer.lock, runcomposer install --no-dev(dev dependencies don't go to prod), then copy the rest of the code. This is where the app is "built". - Stage 3:
prod. Start fresh frombase(clean, no build tools) and copy only the compiled app frombuilder. The final image is lean: no Composer, no unnecessary tools.
You don't have to write this Dockerfile from scratch. The official dunglas/symfony-docker template provides it. You adopt it and read it. Here's a representative excerpt:
FROM dunglas/frankenphp:1-php8.3 AS base
RUN install-php-extensions @composer intl opcache zip
FROM base AS builder
COPY composer.* ./
RUN composer install --no-dev --prefer-dist --no-scripts
COPY . .
RUN composer dump-autoload --optimize
FROM base AS prod
COPY --from=builder /app /app
In practice: the final image doesn't include Composer, asset build sources, or dev dependencies. It's reproducible, lean, and built once by CI. The VPS just downloads it.
:main down to the VPS.All your tests pass, but composer audit finds a CVE in a dependency. What happens to the build job, and what do you need to do to deploy again?
Show the answer
The build job never starts. composer audit is part of the test job: if it fails, the job goes red, and needs: test blocks the image build. A known vulnerability is treated exactly like a broken test: nothing ships. To deploy again, you update the vulnerable dependency (composer update vendor/package), push, and the chain goes green again.
Your turn: CI in action
A simulated terminal, wired to a Symfony project with GitHub Actions configured. You'll push a commit, see CI fail in red (the build job never starts), fix the bug, and watch the image get built and pushed to GHCR.
🎯 Pratique
S'entraîner (clique pour ouvrir) :
💬 Ré-explique sans regarder
Raconte ce qui se passe entre git push et l'image disponible sur GHCR : qui démarre quoi, dans quel ordre, et que se passe-t-il si phpunit échoue ?
on: push déclenche le job test (composer install, composer audit, phpunit) ; si rouge : job build ne démarre jamais ; si vert : job build (needs: test) se connecte à GHCR avec GITHUB_TOKEN, construit l'image, la pousse avec deux tags : :main (la dernière bonne version) et :sha-... (snapshot immuable du commit).🧠 Rappel libre
Sans remonter : pourquoi le VPS ne doit-il rien compiler au moment du déploiement ?
composer install en prod, il a besoin d'internet, d'outils de build, et peut planter au milieu. L'image construite par la CI est une boîte fermée, testée et immuable : le VPS fait juste docker pull. Pas de dépendance réseau en prod, pas de surprise, déploiement identique à chaque fois.⚖️ Juge le conseil de l'IA
Ton job de test prend 4 minutes. L'IA suggère : "Retire needs: test du job build : ça accélère la pipeline puisque les deux jobs tournent en parallèle. Si les tests échouent tu le vois quand même dans les logs." Tu acceptes, ou tu rejettes ?
needs: test, le job build construit et pousse une image sur GHCR même si les tests sont rouges. La leçon 7 la ferait ensuite descendre sur le VPS. Tu déploierais du code cassé. "Tu vois quand même les logs" ne bloque rien : le VPS, lui, ne lit pas les logs. Le gardien existe précisément pour que rien ne sorte sans vert.La CI teste et construit l'image. Maintenant, il faut la faire descendre sur le VPS. À la leçon 7 : une clé SSH dédiée, les secrets GitHub, et un job deploy avec needs: build. Le VPS fait docker pull ghcr.io/toi/ton-projet:main puis docker compose up -d, et les migrations tournent. Un seul git push suffit.
Leçon 7 : Déployer automatiquement →