Le push du vendredi qui m'a appris à interdire le commit sur main
Un git push à 18h un vendredi a déployé direct en production, sans test, et a fait tomber l'API. Rien dans le pipeline ne pouvait l'empêcher, parce qu'il n'y avait pas de pipeline. Voici celui que j'ai construit : main protégée, une CI qui lance les tests, des images construites hors du serveur.
C’était un vendredi, un peu après dix-huit heures. Un petit correctif, un seul fichier, le genre de changement qu’on fait avec un oeil sur la porte. J’ai commité sur main, poussé, ouvert un SSH vers le serveur, lancé git pull puis make deploy SERVICE=api, et je suis allé fermer mon portable.
L’API n’est pas revenue. Le build avait réussi, le conteneur avait démarré, et le processus est mort au boot parce que le seul fichier que j’avais touché importait un module qui existait sur ma machine et pas dans l’image. La production est restée en panne les vingt minutes qu’il m’a fallu pour m’en rendre compte, revenir en arrière et redéployer. Personne n’a été blessé. Tout, dans cette histoire, était évitable.
Ceci est la partie 4 d’une série sur le durcissement d’un SaaS construit en solo en production. Le contexte et la liste complète des points faibles sont dans l’article pilier. Celui-ci parle de l’écart entre « j’ai des tests » et « rien de non testé ne peut atteindre la production », et du déplacement du build Docker hors de la machine qui sert le trafic.
Ce qu’était réellement le déploiement
La description honnête du processus de déploiement, telle que documentée dans le README, était celle-ci :
ssh <user>@<server>
cd app && git pull
make deploy SERVICE=api
Et make deploy faisait ceci (élagué et anonymisé) :
COMPOSE_PROD := docker compose --profile prod
DOCKER_PRUNE_DANGLING := docker image prune -f
deploy:
ifdef SERVICE
$(COMPOSE_PROD) build $(SERVICE)
$(COMPOSE_PROD) up -d --force-recreate --no-build $(SERVICE)
$(DOCKER_PRUNE_DANGLING)
else
$(COMPOSE_PROD) build api
$(COMPOSE_PROD) build --no-cache dashboard
$(COMPOSE_PROD) up -d --force-recreate --no-build api dashboard
$(DOCKER_PRUNE_DANGLING)
endif
Lisez-le comme une liste d’hypothèses de confiance. Il fait confiance à ce qui est sur main pour être correct, parce que rien ne vérifie. Il fait confiance à la personne au clavier pour avoir lancé les tests, parce que rien ne les lance. Il fait confiance au serveur de production pour avoir le CPU et le disque nécessaires à un build d’image, ce qui n’a pas toujours été le cas. Et il fait confiance à l’image construite sur le serveur pour correspondre au code que j’ai testé en local, ce qui, ce vendredi-là, était faux, parce que je ne l’avais jamais construite en local du tout.
Rien de tout cela n’était stupide au lancement. Un développeur, un serveur, un produit qui devait sortir : git pull && make deploy est le chemin le plus court entre un correctif et un correctif qui tourne, et je le rechoisirais pour la première semaine. Ça cesse d’être défendable la première fois qu’un client en voit les conséquences.
Trois barrières, dans l’ordre où elles comptent
La solution a trois parties, et l’ordre est délibéré. Chacune est utile seule, et chacune rend la suivante possible.
Barrière 1 : main refuse les push directs
C’est un réglage GitHub, pas du code, et c’est le changement le moins cher de toute la série. Sur le dépôt : Settings, Branches, ajouter une règle de protection pour main :
- Exiger une pull request avant de fusionner
- Exiger que les status checks passent avant de fusionner, et choisir le check nommé
test(il n’existera pas avant la barrière 2, donc revenez pour celui-là) - Ne pas autoriser le contournement des règles ci-dessus, y compris pour les administrateurs, parce que c’est la ligne qui vous protège réellement de vous-même un vendredi
La première fois qu’on essaie de pousser sur main et qu’on se fait rejeter, c’est légèrement agaçant. Cet agacement est toute la fonctionnalité.
Barrière 2 : une CI qui lance les tests que vous avez déjà
Le dépôt avait exactement un workflow, et il ne parlait pas de qualité de code :
# .github/workflows/precision-check.yml
on:
repository_dispatch:
types: [jira-ticket-moved-to-progress]
Il se déclenche quand un ticket Jira passe « en cours » et demande à un LLM si le ticket est bien spécifié. Utile, mais cela signifie que la seule automatisation du dépôt relisait des tickets, pas du code. Rien ne tournait au push. Rien ne tournait sur pull request. La partie 3 raconte la découverte de vrais tests unitaires qui n’avaient jamais été exécutés une seule fois ; voici le workflow qui les lance enfin, et celui que la barrière 1 attend :
# .github/workflows/test.yml
name: test
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run lint
- run: bun test
Cinq étapes. Il lance le linter et le runner de tests de Bun sur chaque pull request, et sur main elle-même comme filet de sécurité. Le job s’appelle test, qui est le nom que la règle de protection exige. Une fois qu’il est passé au vert une fois, retournez à la barrière 1 et cochez la case, et à partir de là un test rouge bloque le bouton de fusion.
Deux détails à soigner. --frozen-lockfile fait échouer le build si le lockfile n’est pas à jour, ce qui est exactement la classe de bug « ça marche sur ma machine » qui a fait tomber l’API. Et tourner sur push vers main en plus des pull requests attrape tout ce qui atteint main par un chemin non prévu, comme une fusion faite en ligne de commande depuis une branche périmée.
Barrière 3 : construire l’image dans la CI, la tirer sur le serveur
C’est la partie qui change la forme du déploiement, et elle corrige deux points faibles du pilier d’un coup : le build sur la machine de prod, et le déploiement en SSH-et-on-croise-les-doigts.
L’idée : quand main change, GitHub Actions construit les images Docker, les pousse dans un registre, puis dit au serveur de tirer et redémarrer. Le serveur ne construit plus jamais rien. Ses seuls rôles sont de tirer une image qui a déjà passé les tests et de la faire tourner.
# .github/workflows/deploy.yml
name: deploy
on:
push:
branches: [main]
concurrency:
group: deploy-prod
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
service: [api, dashboard]
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
file: apps/${{ matrix.service }}/Dockerfile
target: prod
push: true
tags: |
ghcr.io/${{ github.repository }}/${{ matrix.service }}:${{ github.sha }}
ghcr.io/${{ github.repository }}/${{ matrix.service }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
release:
needs: build
runs-on: ubuntu-latest
steps:
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd app
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
IMAGE_TAG=${{ github.sha }} docker compose --profile prod pull api dashboard
IMAGE_TAG=${{ github.sha }} docker compose --profile prod up -d --no-build api dashboard
docker image prune -f
Le bloc concurrency compte plus qu’il n’y paraît : deux fusions rapprochées ne doivent pas se faire la course jusqu’au serveur, et cancel-in-progress: false signifie que la seconde attend au lieu de tuer la première en plein déploiement.
Côté compose, les deux services arrêtent de construire et commencent à tirer :
# docker-compose.yml, élagué et anonymisé
services:
api:
image: ghcr.io/<org>/<repo>/api:${IMAGE_TAG:-latest}
# build: était ici. Il a disparu.
restart: unless-stopped
...
dashboard:
image: ghcr.io/<org>/<repo>/dashboard:${IMAGE_TAG:-latest}
restart: unless-stopped
...
IMAGE_TAG est le SHA du commit, donc chaque conteneur en cours d’exécution se rattache à exactement un commit qui a passé la CI. Revenir en arrière, c’est IMAGE_TAG=<sha précédent> docker compose up -d, et ça prend quelques secondes, parce que l’image existe déjà dans le registre. Comparez avec la procédure du vendredi, qui était « revert le commit et rebuild sur le serveur pendant qu’il est en panne ».
Le Dockerfile avait déjà un multi-stage target: ${ENVIRONMENT} avec des stages dev et prod, donc épingler target: prod dans la CI n’a demandé aucun changement aux images elles-mêmes.
Ce que ça a fait au Makefile
make deploy existe toujours, parce que la mémoire musculaire est réelle, mais il ne construit plus :
deploy:
@echo "Les déploiements partent de la CI à la fusion sur main."
@echo "Pull manuel d'urgence : make pull TAG=<sha>"
pull:
IMAGE_TAG=$(TAG) $(COMPOSE_PROD) pull api dashboard
IMAGE_TAG=$(TAG) $(COMPOSE_PROD) up -d --no-build api dashboard
Le docker image prune -f qui a sauvé la production quand le disque s’est rempli est toujours dans le script de déploiement, par habitude et par ceinture et bretelles. Mais il ne devrait presque plus rien avoir à élaguer, parce que le serveur ne construit plus et donc n’orphelinise plus d’images. Le problème de disque n’a jamais vraiment été une question d’élagage. C’était une question de construire au mauvais endroit.
Ce qui n’est toujours pas corrigé
Liste honnête.
Les services storage (MinIO) et nginx ne sont pas dans la matrice. MinIO a un petit Dockerfile maison et nginx est d’origine avec une config montée, et les deux changent assez rarement pour que je les aie laissés sur l’ancien chemin pour l’instant. C’est une décision que je reverrai la première fois que l’un des deux aura besoin d’un correctif à chaud.
Il n’y a pas de smoke test après le déploiement. Le job release rapporte un succès quand docker compose up rend la main, pas quand /health répond 200. La partie 6 met un moniteur sur ce endpoint, et la bonne étape suivante est que le job de déploiement le curl et échoue bruyamment s’il ne revient pas.
Et « exiger une pull request » avec un seul développeur, ça veut dire que je relis mon propre code. Ce n’est pas rien : lire un diff dans un navigateur cinq minutes après l’avoir écrit attrape une quantité surprenante de choses, et c’est là que j’aurais vu l’import qui n’existait pas dans l’image. Mais ce n’est pas une seconde paire d’yeux, et je ne vais pas faire semblant que si.
La leçon
L’incident du vendredi n’était pas un échec de test. Les tests existaient. Ce n’était même pas un échec de déploiement au sens habituel : le déploiement a fait exactement ce que je lui ai demandé. C’était un échec de chemin. Il existait une route de mon clavier à la production qui sautait chaque vérification, et un vendredi soir je l’ai prise, parce qu’elle était là.
Les trois barrières ne font pas de moi un ingénieur plus prudent. Elles suppriment la route. main ne prendra pas de push direct, un test rouge ne fusionnera pas, et le serveur ne fera pas tourner une image que la CI n’a pas construite. Chacune est un petit bout de configuration, et ensemble elles font que la pire chose qui puisse arriver à dix-huit heures un vendredi est un push refusé.