xpeditis2.0/docs/mise-en-prod/10-cicd-github-actions.md
David b22f4e0b74 docs: procedure de mise en production pas a pas
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
2026-09-07 21:40:50 +02:00

8.6 KiB

10 — CI/CD GitHub Actions

Durée : environ 1 h 30.

Une fois le premier déploiement manuel réussi, on automatise. Le workflow .github/workflows/cd-main.yml a été réécrit pour cette infrastructure.


1. Enchaînement

push sur main
   │
   ├─ Lint + type-check + tests unitaires (backend, frontend)
   │
   ├─ Vérification : l'image preprod-<sha> existe-t-elle ?
   │     Si non → BLOCAGE. Ce commit n'est pas passé par la preprod.
   │
   ├─ Promotion du backend      preprod-<sha> → prod-<sha>   (aucun rebuild)
   ├─ Reconstruction du frontend avec les URLs de production
   │     + contrôle : aucune URL de preprod dans le bundle
   │
   ├─ Déploiement  (environnement protégé « production »)
   │     1. ouverture du port 22 pour l'IP du runner (firewall Hetzner dédié)
   │     2. rsync de infra/prod vers app-01
   │     3. ssh « deploy prod-<sha> » → migrations, images, attente
   │     4. tests de fumée depuis l'extérieur
   │     5. retour arrière automatique en cas d'échec
   │     6. fermeture du firewall — TOUJOURS, même sur échec ou annulation
   │
   └─ Notification Discord

2. Les trois décisions structurantes

2.1 Le backend est promu, le frontend est reconstruit

Promouvoir garantit que le binaire déployé est exactement celui qui a passé la chaîne de preprod, au condensat près. Un rebuild casserait cette garantie.

Mais next.config.js fige NEXT_PUBLIC_API_URL au moment du build. L'ancien workflow re-taguait l'image frontend de preprod vers la production : le résultat aurait été une application appelant api.preprod.xpeditis.com en production.

Le frontend est donc reconstruit depuis le commit exact déjà vérifié, avec les URLs de production, et une étape de contrôle échoue si la chaîne api.preprod.xpeditis.com se retrouve malgré tout dans le bundle.

2.2 Le déploiement passe par SSH, pas par l'API Kubernetes

L'API k3s (6443) n'est ouverte qu'à vos IP d'administration. Les runners GitHub n'ont pas d'IP fixe, et leurs rangs publiés sont trop vastes et trop mouvants pour une liste blanche.

Le job ouvre donc le port 22 pour la seule IP du runner en cours, via un firewall Hetzner dédié (xpeditis-prod-fw-cicd), puis le referme dans une étape if: always() — donc y compris si le déploiement échoue, si le job est annulé ou s'il expire.

2.3 Le kubeconfig et la clé SOPS ne sont pas dans GitHub

  • Le kubeconfig donne les pleins pouvoirs sur le cluster. Un dépôt compromis ne doit pas les offrir. La CI utilise le kubeconfig local du serveur, à travers une clé SSH restreinte à un script.
  • La clé age déchiffre tous les secrets de production. Les secrets sont appliqués depuis votre poste (make secrets-apply), jamais par la CI.

3. Configurer GitHub

3.1 Environnement protégé

Settings → Environments → New environment → production

Réglage Valeur
Required reviewers vous (au moins une personne)
Deployment branches main uniquement
Wait timer 0

Sans Required reviewers, tout push sur main déploie en production sans validation humaine. Avec, chaque déploiement demande une confirmation explicite — deux secondes qui ont déjà sauvé beaucoup de vendredis soir.

3.2 Secrets et variables

La liste complète, avec la façon d'obtenir chaque valeur, est dans infra/prod/env/github-secrets.md.

Résumé :

Secrets : REGISTRY_TOKEN, HCLOUD_TOKEN_CICD, PROD_SSH_HOST, PROD_SSH_USER, PROD_SSH_KEY, PROD_SSH_KNOWN_HOSTS, NEXT_PUBLIC_API_URL_PROD, NEXT_PUBLIC_APP_URL_PROD, DISCORD_WEBHOOK_URL.

Variables : PROD_API_URL, PROD_APP_URL, HCLOUD_CICD_FIREWALL.

# Empreinte du serveur, pour PROD_SSH_KNOWN_HOSTS
ssh-keyscan -H <app_public_ipv4>

L'empreinte épinglée n'est pas une formalité : sans elle, un détournement DNS ou BGP pourrait rediriger le déploiement — clé SSH comprise — vers une machine tierce.


4. Préparer le serveur

4.1 Répertoire de travail

ssh -i ~/.ssh/xpeditis_prod deploy@<app_public_ipv4> '
  sudo mkdir -p /opt/xpeditis/infra-prod
  sudo chown -R deploy:deploy /opt/xpeditis
'

4.2 Clé de déploiement restreinte

# Clé publique de la CI (générée en 01-prerequis.md)
cat ~/.ssh/xpeditis_ci.pub

Sur app-01, ajoutez-la avec des restrictions :

ssh -i ~/.ssh/xpeditis_prod deploy@<app_public_ipv4> '
  cat >> ~/.ssh/authorized_keys <<EOF
restrict,pty,command="/opt/xpeditis/infra-prod/scripts/ssh-deploy-wrapper.sh" ssh-ed25519 AAAA... github-actions-prod
EOF
  chmod 600 ~/.ssh/authorized_keys
'

restrict désactive le transfert de ports, d'agent et X11. command= force l'exécution du script quelle que soit la commande demandée : une clé volée ne donne pas un shell.

Le wrapper (infra/prod/scripts/ssh-deploy-wrapper.sh) n'accepte que quatre choses : le rsync de infra/prod, deploy prod-<sha> (avec validation stricte du tag), rollback et status. Tout le reste est refusé et journalisé.

4.3 Premier envoi manuel du wrapper

Le wrapper doit exister avant que la clé restreinte ne puisse servir :

rsync -az -e "ssh -i ~/.ssh/xpeditis_prod" \
  infra/prod/ deploy@<app_public_ipv4>:/opt/xpeditis/infra-prod/
ssh -i ~/.ssh/xpeditis_prod deploy@<app_public_ipv4> \
  'chmod +x /opt/xpeditis/infra-prod/scripts/*.sh'

4.4 Tester la clé restreinte

# Autorisé
ssh -i ~/.ssh/xpeditis_ci deploy@<app_public_ipv4> "status"

# Refusé — c'est le résultat attendu
ssh -i ~/.ssh/xpeditis_ci deploy@<app_public_ipv4> "cat /opt/xpeditis/data-node/.env.data"
ssh -i ~/.ssh/xpeditis_ci deploy@<app_public_ipv4>          # pas de shell
ssh -i ~/.ssh/xpeditis_ci deploy@<app_public_ipv4> "deploy ; rm -rf /"

Les refus sont tracés :

ssh deploy@<app_public_ipv4> 'sudo journalctl -t xpeditis-ssh-deploy -n 20'

5. Premier déploiement automatique

git checkout preprod && git merge main && git push        # chaîne preprod
# ... attendre que cd-preprod.yml passe au vert ...

git checkout main && git merge preprod && git push

Suivez l'exécution dans l'onglet Actions. Le job deploy attendra votre approbation.

Vérifier que le firewall s'est bien refermé

hcloud firewall describe xpeditis-prod-fw-cicd

Attendu : aucune règle. S'il en reste une, une exécution a été interrompue d'une façon qui a contourné le if: always() :

echo '[]' > /tmp/empty.json
hcloud firewall replace-rules xpeditis-prod-fw-cicd --rules-file /tmp/empty.json

Ajoutez ce contrôle à votre routine hebdomadaire.


6. Le workflow de retour arrière

.github/workflows/rollback.yml existe déjà. Vérifiez qu'il cible bien la nouvelle infrastructure ; à défaut, le retour arrière manuel reste disponible :

ssh -i ~/.ssh/xpeditis_ci deploy@<app_public_ipv4> "rollback"
# ou
make -C infra/prod rollback

Le retour arrière ne défait pas les migrations. Si la version retirée contenait une migration destructrice (colonne supprimée, type modifié), l'ancienne version applicative peut ne plus fonctionner contre le schéma courant. Voir 15 § Retour arrière avec migration.


7. Ce que la chaîne ne fait pas

Elle est volontairement conservatrice. Elle ne fait pas :

  • de tests end-to-end contre la production (Playwright tourne sur la preprod) ;
  • de déploiement bleu-vert ou canari — inutile à cette échelle, le maxUnavailable: 0 suffit à éviter toute coupure ;
  • de sauvegarde avant déploiement — la sauvegarde quotidienne et l'archivage continu couvrent le besoin. Avant une migration risquée, prenez une sauvegarde manuelle (12) ;
  • d'analyse de vulnérabilité des images. À ajouter (Trivy) quand vous en aurez le temps ; ce n'est pas bloquant pour le lancement.

8. Contrôle

[ ] Environnement GitHub « production » créé, Required reviewers actif
[ ] Branches de déploiement limitées à main
[ ] 9 secrets renseignés
[ ] 3 variables renseignées
[ ] /opt/xpeditis/infra-prod créé et appartenant à deploy
[ ] Clé CI ajoutée avec restrict + command=
[ ] « status » fonctionne, tout le reste est refusé
[ ] Refus visibles dans journalctl -t xpeditis-ssh-deploy
[ ] Premier déploiement automatique réussi
[ ] Firewall CI vide après exécution
[ ] Notification Discord reçue

→ Suite : 11 — Observabilité