Files
Forge-Engine/.gitea/workflows/deploy.yml
T
williamandClaude Sonnet 5 20f9398bd4 Phase 4 (CI) : securise le job deploy (token registre, cle SSH)
- docker login sur le serveur distant : passe le token via
  --password-stdin (echo | docker login ...) plutot qu'en argument -p,
  meme methode que build-and-push - un -p en argument reste visible via
  ps sur le serveur tant que le process tourne.
- Nouvelle etape "Nettoyage de la cle SSH" (if: always()) : supprime
  ~/.ssh/deploy_key en fin de job, meme si une etape precedente a
  echoue. rm -f (jamais rm nu) : sortie 0 que le fichier ou meme le
  dossier ~/.ssh parent soit deja absent, verifie empiriquement - ne
  peut jamais faire echouer ce nettoyage ni masquer un echec anterieur.
  Le runner semble deja jetable (docker volume rm observe dans les logs
  d'un autre job de ce meme workflow), mais jamais verifie directement
  pour deploy (jamais execute sur dev, reserve a main) - nettoyage
  explicite plutot qu'une inference par analogie pour une cle privee.

djLint (H021, styles inline) volontairement saute pour ce commit - meme
backlog assume que les commits Phase 3/4 precedents, aucun rapport avec
ce changement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 19:39:39 +02:00

224 lines
10 KiB
YAML

name: Build and deploy
on:
push:
branches: [main, dev]
# Les jobs "test-*"/"lint-*"/"sonarqube" tournent sur CHAQUE push (main et
# dev) : jusqu'ici aucune étape de CI n'exécutait la suite de tests ni les
# outils qualité, rien n'empêchait un commit cassé d'atteindre la
# production (voir l'audit qualité de la Phase 0 du plan). "build-and-
# push"/"deploy", eux, restent réservés à main (via le filtre "if" sur
# gitea.ref) — un push sur dev ne doit jamais redéployer la prod, seulement
# faire tourner tests/lint/sonar.
#
# lint-python/lint-js rejouent EXACTEMENT les hooks pre-commit locaux
# (.pre-commit-config.yaml) mais bloquants ici dès le départ (déjà tous
# verts en local, voir le commit "Phase 3 : hardening qualite de code") —
# les hooks pre-commit ne protègent que la machine du committeur, jamais
# un push direct ou une PR mergée depuis ailleurs. djlint EXCLU
# volontairement (49 H021 "styles inline" déjà en backlog assumé, voir
# CODE_QUALITY.md) — à ajouter ici quand ce lot sera traité. sonarqube,
# lui, reste NON-BLOQUANT (continue-on-error) pendant cette première
# période — voir CODE_QUALITY.md pour la trajectoire vers un mode
# bloquant une fois le rapport trié (code smells, vulnerabilites) plutôt
# que de bloquer tout de suite sur des centaines de signalements pas
# encore triés.
# Secrets à configurer dans Gitea (Paramètres du dépôt > Actions > Secrets) :
# REGISTRY_HOST adresse du registre d'images (ex: gitea.exemple.com)
# REGISTRY_IMAGE chemin complet de l'image (ex: gitea.exemple.com/mon-compte/forge-engine)
# REGISTRY_USER utilisateur pour "docker login" sur ce registre
# REGISTRY_TOKEN jeton d'accès (scope package:write) pour ce registre
# DEPLOY_HOST adresse du serveur de production
# DEPLOY_USER utilisateur SSH sur ce serveur
# DEPLOY_SSH_KEY clé privée SSH (au format PEM) autorisée sur ce serveur
# DEPLOY_PATH dossier sur le serveur où vit docker-compose.prod.yml (ex: /home/deploy/forge-engine)
# SONAR_TOKEN jeton d'analyse SonarQube (Mon compte > Security > Generate Token
# sur sonar.forgebase.fr) — jamais le mot de passe admin.
#
# Utilise directement docker/ssh/scp en ligne de commande plutôt que des
# actions du marketplace, pour ne pas dépendre de la disponibilité de
# github.com / du miroir gitea.com depuis le runner self-hosted.
jobs:
# Deux essais précédents ont échoué :
# 1. docker run -v "$PWD":/app ... imbriqué depuis le runner — sur un
# runner qui exécute déjà le job dans son propre conteneur (Docker-
# outside-of-Docker), "$PWD" ne correspond à aucun chemin que le démon
# Docker de l'HÔTE peut monter : /app se retrouvait vide.
# 2. jobs.test.container: image: python:3.13-slim — actions/checkout@v4
# (une action Node.js) a alors besoin de Node dans CE conteneur pour
# s'exécuter, qui ne l'a pas ("command not found", voir
# nektos/act#107) : `container:` remplace tout l'environnement des
# steps, pas seulement celui des commandes qu'on y lance soi-même.
# Solution : le job tourne sur le runner PAR DÉFAUT (checkout fonctionne,
# Node y est déjà disponible), et les tests s'exécutent PENDANT un
# `docker build` (Dockerfile passé par stdin, jamais commité) — le
# transfert du contexte de build au démon Docker ne dépend JAMAIS d'un
# chemin hôte/montage de volume (protocole API, un tar envoyé tel quel),
# donc insensible au problème Docker-outside-of-Docker du point 1.
test-python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Tests Python (pytest, exécutés PENDANT le build d'une image jetable)
run: |
# .dockerignore (à la racine, pensé pour l'image de PROD) exclut
# tests/ du contexte de build — COPY . . ne l'aurait donc jamais
# inclus, quel que soit le Dockerfile utilisé (.dockerignore
# s'applique au contexte entier, pas à un Dockerfile en
# particulier). Neutralisé ici SANS RISQUE : ce checkout est
# propre à ce job, jetable, jamais repoussé vers le dépôt.
mv .dockerignore .dockerignore.disabled-for-ci
docker build -f - -t forge-test-python:${{ gitea.sha }} . <<'DOCKERFILE'
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt requirements-dev.txt ./
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY . .
RUN python -m pytest tests/ -q
DOCKERFILE
test-js:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Tests JS (node:test — logique pure de static/js/play/, et l'assistant déclencheurs de static/js/scenes/+static/js/triggers/, voir le plan de modularisation)
run: |
docker build -f - -t forge-test-js:${{ gitea.sha }} . <<'DOCKERFILE'
FROM node:20-slim
WORKDIR /app
COPY static/js/play/ static/js/play/
COPY static/js/scenes/ static/js/scenes/
COPY static/js/triggers/ static/js/triggers/
RUN node --test static/js/play/__tests__/*.test.js static/js/scenes/__tests__/*.test.js
DOCKERFILE
lint-python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Qualite Python (ruff/mypy/vulture/bandit/import-linter — memes commandes que .pre-commit-config.yaml)
run: |
# Meme neutralisation de .dockerignore que test-python ci-dessus :
# ruff/mypy typent aussi tests/ (voir le plan de typage strict),
# exclu par defaut de l'image de PROD.
mv .dockerignore .dockerignore.disabled-for-ci
docker build -f - -t forge-lint-python:${{ gitea.sha }} . <<'DOCKERFILE'
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt requirements-dev.txt ./
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY . .
RUN ruff check .
RUN ruff format --check .
RUN mypy .
RUN vulture
RUN bandit -c pyproject.toml -r ai auth core db filters publish routes screens scripts app.py build_css.py
RUN lint-imports
DOCKERFILE
lint-js:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Qualite JS/CSS (eslint/stylelint — memes commandes que .pre-commit-config.yaml, via les scripts npm de package.json)
run: |
docker build -f - -t forge-lint-js:${{ gitea.sha }} . <<'DOCKERFILE'
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run lint:js
RUN npm run lint:css
DOCKERFILE
sonarqube:
runs-on: ubuntu-latest
# Non-bloquant pendant cette premiere periode (voir le bloc de
# commentaires en tete de fichier) — un echec ici n'empeche jamais
# build-and-push/deploy, contrairement a lint-python/lint-js.
continue-on-error: true
steps:
- uses: actions/checkout@v4
- name: Analyse SonarQube (rapport seul, non-bloquant)
run: |
# Meme neutralisation de .dockerignore que test-python : sonar-
# project.properties couvre aussi tests/ (sonar.tests).
mv .dockerignore .dockerignore.disabled-for-ci
docker build -f - -t forge-sonar:${{ gitea.sha }} . <<'DOCKERFILE'
FROM sonarsource/sonar-scanner-cli:latest
WORKDIR /usr/src
COPY . .
DOCKERFILE
docker run --rm forge-sonar:${{ gitea.sha }} \
-Dsonar.host.url=https://sonar.forgebase.fr \
-Dsonar.token=${{ secrets.SONAR_TOKEN }}
build-and-push:
needs: [test-python, test-js, lint-python, lint-js]
if: gitea.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Connexion au registre d'images
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ secrets.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Build de l'image
run: |
docker build \
-t "${{ secrets.REGISTRY_IMAGE }}:${{ gitea.sha }}" \
-t "${{ secrets.REGISTRY_IMAGE }}:latest" \
.
- name: Publication de l'image
run: |
docker push "${{ secrets.REGISTRY_IMAGE }}:${{ gitea.sha }}"
docker push "${{ secrets.REGISTRY_IMAGE }}:latest"
deploy:
needs: build-and-push
if: gitea.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Préparation de la clé SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
- name: Envoi du docker-compose.prod.yml sur le serveur
run: |
scp -i ~/.ssh/deploy_key docker-compose.prod.yml \
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH }}/docker-compose.prod.yml"
- name: Pull et redémarrage sur le serveur
run: |
ssh -i ~/.ssh/deploy_key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" '
set -e
cd "${{ secrets.DEPLOY_PATH }}"
echo "REGISTRY_IMAGE=${{ secrets.REGISTRY_IMAGE }}" > .env
echo "IMAGE_TAG=${{ gitea.sha }}" >> .env
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ secrets.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
docker image prune -f
'
- name: Nettoyage de la cle SSH
# if: always() - meme si les etapes precedentes ont echoue, la cle
# privee posee ci-dessus (~/.ssh/deploy_key) ne doit jamais rester
# sur le disque du job. Le runner semble deja jetable (voir
# "docker volume rm .../JOB-<id>-..." dans les logs des autres
# jobs de ce meme workflow), mais jamais verifie directement pour
# CE job (jamais execute sur dev, reserve a main) - ne pas se fier
# uniquement a une inference par analogie pour une cle privee.
if: always()
run: rm -f ~/.ssh/deploy_key