diff --git a/.claude/deploiement.md b/.claude/deploiement.md
index 51989dc1..95415350 100644
--- a/.claude/deploiement.md
+++ b/.claude/deploiement.md
@@ -624,6 +624,19 @@ cat .deployed-sha # SHA actuellement déployé
tail -20 var/log/deploy-nightly.log # dernière tentative
```
+### Garde-fous : `composer` en CLI, vérification de `vendor/`, rollback (#570)
+
+Incident du 2026-10-02 : le déploiement nocturne a lancé composer avec le PHP **CGI** du PATH cron. Composer a affiché son aide et **sorti en code 0** sans rien installer ; le code avait déjà été mis à jour par `git checkout`, `vendor/` est resté ancien (3 paquets manquants : `monolog/monolog`, `symfony/monolog-bridge`, `symfony/monolog-bundle`) et 6 instances sont passées en HTTP 500.
+
+Ce qui est en place depuis, **commun aux trois scripts** (`bin/lib/deploy-common.sh`, sourcé par `deploy-nightly.sh`, `deploy-all.sh` et `deploy.sh`) :
+
+- **composer via le PHP CLI explicite** : `$HC_PHP -d memory_limit=512M /usr/local/bin/composer install …`. `$HC_COMPOSER_BIN` vaut plusieurs mots : toujours l'utiliser **non quoté** (entre guillemets, bash cherche un exécutable nommé « php composer » et sort en 127). Plus aucun `composer` nu dans `bin/` (test `test_aucun_composer_nu_dans_les_scripts_de_deploiement`).
+- **Vérification de `vendor/`** après `composer install` : `composer install --dry-run --no-dev` doit afficher « Nothing to install, update or remove ». Sinon l'étape « vérification de vendor/ » échoue (validé avec le vrai composer : message présent sur ronan, absent sur une instance à `vendor/` incomplet).
+- **Rollback automatique si l'échec précède les migrations** : `git checkout --force
`, `composer install`, vérification de `vendor/`, `cache:clear`. Le rapport indique `→ code restauré ()` ou `→ ROLLBACK ÉCHOUÉ …, instance probablement hors service` (alors : intervention manuelle). **Pas de rollback à partir des migrations** : l'état de la base est incertain (migration partielle), un ancien code sur un schéma à moitié migré serait pire. Pas de rollback non plus si `HEAD` est déjà la cible.
+- La cible du rollback est le `HEAD` réellement en place avant l'opération, **pas** `.deployed-sha` (un déploiement interrompu laisse `HEAD` sur le nouveau code alors que `.deployed-sha` reste ancien).
+- Un correctif du script nocturne n'est actif sur une instance qu'**à la nuit suivant celle où elle l'a récupéré** (le script du cron est celui du disque au lancement). Après un correctif de script, passer par `bash bin/deploy-all.sh` (exécuté depuis le poste) plutôt que d'attendre le cron.
+- Tests : `bash tests/bash/run.sh` (28 tests). Ils ne tournent pas dans la CI.
+
### Crons cPanel — créés et actifs depuis le 2026-09-12
> Vérifié en SSH le 2026-09-12 22h : les 8 crons ci-dessous sont bien
diff --git a/.github/avancement.md b/.github/avancement.md
index 151009c7..7e0b526f 100644
--- a/.github/avancement.md
+++ b/.github/avancement.md
@@ -6,6 +6,18 @@
---
+## 🚧 Déploiement : composer en CLI, vérification de vendor/, rollback (2026-10-02, #570, branche `fix/570-deploy-composer-rollback`)
+
+- Cause de l'incident #569 (6 instances en 500) : composer lancé en PHP CGI par le cron affichait son aide et sortait en 0 ; code déjà mis à jour par `git checkout`, `vendor/` ancien, aucun rollback.
+- Le correctif `61ba614` était lui-même cassé : `"$COMPOSER_BIN"` (deux mots) entre guillemets → exit 127 « commande introuvable ». 7 tests bash échouaient déjà sur `main` sans que personne le voie (les tests bash ne tournent pas en CI).
+- `bin/lib/deploy-common.sh` : définition unique de composer (PHP CLI explicite, `memory_limit`) et de la vérification de `vendor/`, sourcée par `deploy-nightly.sh`, `deploy-all.sh` et `deploy.sh`. Plus de `composer` nu dans `bin/`.
+- Vérification de `vendor/` (`composer install --dry-run` → « Nothing to install ») validée avec le vrai composer : présent sur ronan, absent sur yannick (3 paquets manquants listés).
+- Rollback si l'échec précède les migrations (jamais après : état de la base incertain), cible = `HEAD` réellement en place ; rapport `→ code restauré ()` / `→ ROLLBACK ÉCHOUÉ`.
+- Tests bash : 28/28 (dont 11 nouveaux, 7 réparés). Suite PHP non concernée.
+- Reste : revue, `gh pr create` (label + `Closes #570` + assignee + board), CI verte, merge. Piste proposée : exécuter `tests/bash/run.sh` en CI.
+
+---
+
## ✅ Mises à jour composer — Symfony 8.0 → 8.1 (2026-10-02, #538, PR #562 mergée)
- Contraintes `symfony/*` et `extra.symfony.require` passées de `8.0.*` à `8.1.*` (un `composer update` seul n'aurait rien monté). Symfony en 8.1.8, Doctrine ORM 3.7.3, DoctrineBundle 3.3.2, phpunit 13.4.0, twig 3.30, monolog 3.12.1.
diff --git a/bin/deploy-all.sh b/bin/deploy-all.sh
index c261d565..921c9d9e 100755
--- a/bin/deploy-all.sh
+++ b/bin/deploy-all.sh
@@ -79,12 +79,14 @@ SSH_HOST="lenouvel.me"
SSH_PORT=22
GIT_REPO="https://github.com/ronan-develop/home-cloud"
GIT_BRANCH="main"
-# -d memory_limit=512M : sur le mutualisé o2switch (LVE CloudLinux), le
-# memory_limit par défaut du php.ini fait tuer cache:clear --env=prod même
-# isolé dans son propre process SSH (vécu 2026-09-27) — la valeur par défaut
-# est trop juste pour la compilation du container Symfony en prod.
-PHP_BIN="/usr/local/bin/php -d memory_limit=512M"
-COMPOSER_BIN="composer"
+# PHP/composer (chemins absolus, composer via PHP CLI explicite, memory_limit)
+# et vérification de vendor/ : une seule définition, partagée avec
+# deploy-nightly.sh (#570). Valeurs de plusieurs mots : utilisées dans des
+# chaînes de commande distantes, jamais comme un seul argument.
+# shellcheck source=lib/deploy-common.sh
+source "${SCRIPT_DIR}/lib/deploy-common.sh" || exit 1
+PHP_BIN="$HC_PHP_BIN"
+COMPOSER_BIN="$HC_COMPOSER_BIN"
SSH_KEY_OPTS=""
if [[ -n "${SSH_KEY_PATH:-}" && -f "${SSH_KEY_PATH}" ]]; then
@@ -157,6 +159,23 @@ run_step() {
return 0
}
+# ── Restauration du code précédent (#570) ────────────────────────────────────
+# Appelée seulement si l'échec précède les migrations : après, l'état de la
+# base est incertain (migration partielle) et un ancien code sur un schéma à
+# moitié migré serait pire que le nouveau code.
+rollback_remote() {
+ local sha="$1"
+ warn "${SUBDOMAIN} — restauration du code précédent (${sha:0:7})…"
+ if run_step "rollback git checkout" "git checkout --force ${sha}" \
+ && run_step "rollback composer install" "${COMPOSER_BIN} ${HC_COMPOSER_INSTALL_ARGS}" \
+ && run_step "rollback vérification de vendor/" "${HC_VERIFY_VENDOR_SNIPPET}" \
+ && run_step "rollback cache:clear" "${PHP_BIN} bin/console cache:clear --env=prod"; then
+ success "${SUBDOMAIN} — code restauré (${sha:0:7})"
+ else
+ error "${SUBDOMAIN} — ROLLBACK ÉCHOUÉ, instance probablement hors service : intervention manuelle requise"
+ fi
+}
+
# ── Build Tailwind une seule fois, hors boucle (#421) ─────────────────────────
# Le CSS est identique pour les 7 instances — le reconstruire à chaque
# itération était un gaspillage pur, déjà vrai avant #421.
@@ -230,7 +249,8 @@ ENVEOF
continue
fi
- if run_step "composer install" "${COMPOSER_BIN} install --no-interaction --prefer-dist --no-progress --no-dev --no-scripts" \
+ if run_step "composer install" "${COMPOSER_BIN} ${HC_COMPOSER_INSTALL_ARGS}" \
+ && run_step "vérification de vendor/" "${HC_VERIFY_VENDOR_SNIPPET}" \
&& run_step "install-ffmpeg" "bash bin/install-ffmpeg.sh || echo '⚠ ffmpeg non installé — vignettes vidéo indisponibles'" \
&& run_step "cache:clear" "${PHP_BIN} bin/console cache:clear --env=prod" \
&& run_step "assets:install" "${PHP_BIN} bin/console assets:install public --env=prod" \
@@ -271,12 +291,20 @@ ENVEOF
# app.built.css vient d'être écrasé par le scp ci-dessus : annuler cette
# modification locale avant le pull, sinon git refuse de merger
# ("Your local changes ... would be overwritten by merge").
+ # Code réellement en place avant le pull : cible du rollback (#570).
+ PREV_SHA=$(ssh ${SSH_KEY_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${SSH_HOST}" "cd ${DEPLOY_PATH} && git rev-parse HEAD" 2>/dev/null || echo "")
+ CODE_READY=false
if run_step "git pull" "git checkout -- var/tailwind/app.built.css 2>/dev/null; mkdir -p var/log && git pull origin ${GIT_BRANCH}" \
- && run_step "composer install" "${COMPOSER_BIN} install --no-interaction --prefer-dist --no-progress --no-dev --no-scripts" \
+ && run_step "composer install" "${COMPOSER_BIN} ${HC_COMPOSER_INSTALL_ARGS}" \
+ && run_step "vérification de vendor/" "${HC_VERIFY_VENDOR_SNIPPET}" \
&& run_step "install-ffmpeg" "bash bin/install-ffmpeg.sh || echo '⚠ ffmpeg non installé — vignettes vidéo indisponibles'" \
&& run_step "cache:clear" "${PHP_BIN} bin/console cache:clear --env=prod" \
&& run_step "assets:install" "${PHP_BIN} bin/console assets:install public --env=prod" \
- && run_step "importmap:install" "${PHP_BIN} bin/console importmap:install --env=prod" \
+ && run_step "importmap:install" "${PHP_BIN} bin/console importmap:install --env=prod"; then
+ CODE_READY=true
+ fi
+
+ if [[ "$CODE_READY" == true ]] \
&& run_step "migrations" "${PHP_BIN} bin/console doctrine:migrations:migrate --no-interaction --env=prod" \
&& run_step "asset-map:compile" "${PHP_BIN} bin/console asset-map:compile" \
&& run_step "deploy-info" "echo '${DEPLOY_INFO_LINE}' > templates/deploy-info.html.twig" \
@@ -287,6 +315,9 @@ ENVEOF
success "${SUBDOMAIN} — mise à jour OK"
RESULTS_OK+=("$SUBDOMAIN")
else
+ if [[ "$CODE_READY" != true && -n "$PREV_SHA" ]]; then
+ rollback_remote "$PREV_SHA"
+ fi
RESULTS_FAIL+=("$SUBDOMAIN")
fi
fi
diff --git a/bin/deploy-nightly.sh b/bin/deploy-nightly.sh
index 5a1a3fbc..ac5acd12 100755
--- a/bin/deploy-nightly.sh
+++ b/bin/deploy-nightly.sh
@@ -19,26 +19,14 @@ PRENOM="${1:?Usage: deploy-nightly.sh /dev/null || echo "")
+
+# Restaure le code précédent et un vendor/ cohérent avec son composer.lock.
+# Appelée seulement quand l'échec survient AVANT les migrations : après, l'état
+# de la base est incertain (migration partielle) et un ancien code sur un schéma
+# à moitié migré serait pire que le nouveau code.
+ROLLBACK_NOTE=""
+rollback_code() {
+ local sha="$1"
+ local original_failed_step="$FAILED_STEP"
+ echo "↩ ${PRENOM} — restauration du code précédent (${sha:0:7})" >&2
+ if run_step "rollback git checkout" git checkout --force "$sha" \
+ && run_step "rollback composer install" $COMPOSER_BIN $HC_COMPOSER_INSTALL_ARGS \
+ && run_step "rollback vérification de vendor/" hc_verify_vendor \
+ && run_step "rollback cache:clear" $PHP_BIN bin/console cache:clear --env=prod; then
+ ROLLBACK_NOTE=" → code restauré (${sha:0:7})"
+ else
+ ROLLBACK_NOTE=" → ROLLBACK ÉCHOUÉ à « ${FAILED_STEP} », instance probablement hors service"
+ fi
+ FAILED_STEP="$original_failed_step"
+}
+
+CODE_READY=false
if run_step "git checkout" git checkout --force "$REMOTE_SHA" \
-&& run_step "composer install" "$COMPOSER_BIN" install --no-interaction --prefer-dist --no-progress --no-dev --no-scripts \
+&& run_step "composer install" $COMPOSER_BIN $HC_COMPOSER_INSTALL_ARGS \
+&& run_step "vérification de vendor/" hc_verify_vendor \
&& run_step "install-ffmpeg" bash bin/install-ffmpeg.sh \
&& run_step "cache:clear" $PHP_BIN bin/console cache:clear --env=prod \
&& run_step "assets:install" bash -c "umask 022 && $PHP_BIN bin/console assets:install public --env=prod" \
-&& run_step "importmap:install" bash -c "umask 022 && $PHP_BIN bin/console importmap:install --env=prod" \
+&& run_step "importmap:install" bash -c "umask 022 && $PHP_BIN bin/console importmap:install --env=prod"; then
+ CODE_READY=true
+fi
+
+if [[ "$CODE_READY" == true ]] \
&& run_step "migrations" $PHP_BIN bin/console doctrine:migrations:migrate --no-interaction --env=prod \
&& run_step "asset-map:compile" bash -c "umask 022 && $PHP_BIN bin/console asset-map:compile"; then
rm -f "$IMMINENT_FILE"
@@ -156,12 +174,18 @@ if run_step "git checkout" git checkout --force "$REMOTE_SHA" \
exit 0
else
rm -f "$IMMINENT_FILE"
- echo "${PRENOM} : échec du déploiement, .deployed-sha inchangé." >&2
+ # Rollback seulement si l'échec précède les migrations ET qu'il y a un
+ # code différent à restaurer (si HEAD est déjà la cible, un second
+ # checkout n'améliorerait rien).
+ if [[ "$CODE_READY" != true && -n "$PREVIOUS_HEAD" && "$PREVIOUS_HEAD" != "$REMOTE_SHA" ]]; then
+ rollback_code "$PREVIOUS_HEAD"
+ fi
+ echo "${PRENOM} : échec du déploiement${ROLLBACK_NOTE}, .deployed-sha inchangé." >&2
if [[ "$IS_CRITICAL" == true ]]; then
- report_line "critical" "${FAILED_STEP:-inconnue}"
+ report_line "critical" "${FAILED_STEP:-inconnue}${ROLLBACK_NOTE}"
open_critical_ticket "${FAILED_STEP:-inconnue}"
else
- report_line "failed" "${FAILED_STEP:-inconnue}"
+ report_line "failed" "${FAILED_STEP:-inconnue}${ROLLBACK_NOTE}"
fi
exit 1
fi
diff --git a/bin/deploy.sh b/bin/deploy.sh
index 5a26cd7d..10befeba 100755
--- a/bin/deploy.sh
+++ b/bin/deploy.sh
@@ -56,8 +56,12 @@ if [[ -n "${SSH_KEY_PATH:-}" && -f "$SSH_KEY_PATH" ]]; then
SSH_KEY_OPTS="-i ${SSH_KEY_PATH}"
fi
GIT_BRANCH="main"
-PHP_BIN="/usr/local/bin/php"
-COMPOSER_BIN="composer"
+# PHP/composer en chemin absolu, composer via PHP CLI explicite : définis une
+# seule fois dans bin/lib/deploy-common.sh (#570).
+# shellcheck source=lib/deploy-common.sh
+source "${SCRIPT_DIR}/lib/deploy-common.sh" || exit 1
+PHP_BIN="$HC_PHP_BIN"
+COMPOSER_BIN="$HC_COMPOSER_BIN"
# ── Questionnaire ─────────────────────────────────────────────────────────────
title "═══════════════════════════════════════"
@@ -189,7 +193,8 @@ if [[ "$UPDATE_MODE" == true ]]; then
ssh ${SSH_KEY_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${SSH_HOST}" \
"cd ${DEPLOY_PATH} && \
git pull origin main && \
- ${COMPOSER_BIN} install --no-interaction --prefer-dist --no-progress --no-dev && \
+ ${COMPOSER_BIN} ${HC_COMPOSER_INSTALL_ARGS} && \
+ ( ${HC_VERIFY_VENDOR_SNIPPET} ) && \
${PHP_BIN} bin/console cache:clear --env=prod && \
rm -rf var/cache/prod/* && \
rm -rf public/assets/* && \
@@ -200,7 +205,7 @@ if [[ "$UPDATE_MODE" == true ]]; then
{ error "❌ Erreur lors du déploiement."; exit 1; }
else
info "Mode primo déploiement : clonage repo + setup"
- ssh ${SSH_KEY_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${SSH_HOST}" "mkdir -p ${DEPLOY_PATH} && cd ${DEPLOY_PATH} && git clone ${GIT_REPO} . && composer install --no-interaction --prefer-dist --no-progress && bash bin/install-ffmpeg.sh || echo '⚠ ffmpeg non installé — vignettes vidéo indisponibles'" && \
+ ssh ${SSH_KEY_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${SSH_HOST}" "mkdir -p ${DEPLOY_PATH} && cd ${DEPLOY_PATH} && git clone ${GIT_REPO} . && ${COMPOSER_BIN} ${HC_COMPOSER_INSTALL_ARGS} && ( ${HC_VERIFY_VENDOR_SNIPPET} ) && bash bin/install-ffmpeg.sh || echo '⚠ ffmpeg non installé — vignettes vidéo indisponibles'" && \
success "✅ Déploiement réussi !" || \
{ error "❌ Erreur lors du déploiement."; exit 1; }
fi
diff --git a/bin/lib/deploy-common.sh b/bin/lib/deploy-common.sh
new file mode 100644
index 00000000..40c61e43
--- /dev/null
+++ b/bin/lib/deploy-common.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+# =============================================================================
+# Définitions communes aux scripts de déploiement (#570) — SOURCÉ par
+# deploy-nightly.sh, deploy-all.sh et deploy.sh, jamais exécuté directement.
+#
+# Une seule définition de la commande composer et de la vérification de
+# vendor/, pour qu'un correctif ne puisse plus être appliqué à un script et
+# oublié dans les deux autres (le correctif 61ba614 n'avait touché que
+# deploy-nightly.sh).
+# =============================================================================
+
+# Chemins absolus obligatoires : un cron cPanel s'exécute avec un PATH minimal
+# (pas celui du profil shell interactif) — "composer"/"php" seuls ne résolvent
+# à rien et font échouer le déploiement en silence (#421, échec réel constaté
+# la nuit du 2026-09-12 : « composer : commande introuvable »).
+HC_PHP="${DEPLOY_NIGHTLY_PHP_BIN:-/usr/local/bin/php}"
+# -d memory_limit=512M : sur le mutualisé o2switch (LVE CloudLinux), le
+# memory_limit par défaut du php.ini fait tuer cache:clear --env=prod même
+# isolé dans son propre process SSH (vécu 2026-09-27) — la valeur par défaut
+# est trop juste pour la compilation du container Symfony en prod.
+HC_PHP_BIN="${HC_PHP} -d memory_limit=512M"
+
+# composer est un script `#!/usr/bin/env php` : sous le PATH minimal du cron,
+# `env` résout "php" vers le CGI, pas le CLI. Composer affiche alors son aide
+# et SORT EN 0 sans rien installer (constaté le 2026-10-02 : vendor/ resté
+# sans symfony/monolog-bundle, 6 instances en HTTP 500). Invoquer composer.phar
+# via le PHP CLI explicite court-circuite ce shebang. Valeur de plusieurs mots :
+# toujours l'utiliser NON quoté ($HC_COMPOSER_BIN), comme $HC_PHP_BIN.
+HC_COMPOSER_BIN="${HC_PHP_BIN} ${DEPLOY_NIGHTLY_COMPOSER_PHAR:-/usr/local/bin/composer}"
+HC_COMPOSER_INSTALL_ARGS="install --no-interaction --prefer-dist --no-progress --no-dev --no-scripts"
+
+# Vérifie que vendor/ correspond exactement à composer.lock : après un
+# install réussi, un dry-run ne doit plus rien avoir à faire. Un composer qui
+# a affiché son aide (CGI) ou s'est arrêté en route ne produit pas ce message.
+# Snippet de shell (et non une fonction seule) pour pouvoir aussi être envoyé
+# tel quel dans une commande SSH distante (deploy-all.sh).
+HC_VERIFY_VENDOR_SNIPPET='out=$('"${HC_COMPOSER_BIN}"' install --dry-run --no-dev --no-scripts --no-interaction 2>&1); case "$out" in *"Nothing to install, update or remove"*) ;; *) echo "vendor/ ne correspond pas à composer.lock (composer non exécuté en CLI ?) :" >&2; printf "%s\n" "$out" | head -8 >&2; exit 1 ;; esac'
+
+# À appeler dans un sous-shell (run_step) : sort en 1 si vendor/ est incomplet.
+hc_verify_vendor() {
+ eval "$HC_VERIFY_VENDOR_SNIPPET"
+}
diff --git a/tests/bash/deploy-all-routing-test.sh b/tests/bash/deploy-all-routing-test.sh
index c3096621..4f738d3f 100644
--- a/tests/bash/deploy-all-routing-test.sh
+++ b/tests/bash/deploy-all-routing-test.sh
@@ -50,10 +50,18 @@ if [[ "$*" == *"echo OK"* ]]; then
echo "OK"
elif [[ "$*" == *'echo $HOME'* ]]; then
echo "/home9/ron2cuba"
+elif [[ "$*" == *"git rev-parse HEAD"* && "$*" != *".deployed-sha"* ]]; then
+ # HEAD distant avant la mise à jour (sert au rollback, #570)
+ echo "prev1234567"
+fi
+# Échec simulé UNE seule fois sur la première commande contenant le motif
+if [[ -n "${SSH_FAIL_ONCE_PATTERN:-}" && "$*" == *"${SSH_FAIL_ONCE_PATTERN}"* && ! -f "__MARKER__" ]]; then
+ touch "__MARKER__"
+ exit 1
fi
exit 0
EOF
- sed -i "s|__CALL_LOG__|${CALL_LOG}|" "${STUB_BIN}/ssh"
+ sed -i "s|__CALL_LOG__|${CALL_LOG}|; s|__MARKER__|${FIXTURE_DIR}/ssh-failed-once|g" "${STUB_BIN}/ssh"
chmod +x "${STUB_BIN}/ssh"
cat > "${STUB_BIN}/scp" < /dev/null 2>&1
+
+ assert_contains "$(cat "$CALL_LOG")" "Nothing to install, update or remove"
+ assert_contains "$(cat "$CALL_LOG")" "composer install --no-interaction"
+
+ _teardown_routing_fixture
+}
+
+test_echec_avant_migrations_restaure_le_code_precedent() {
+ _setup_routing_fixture
+ _write_routing_stubs
+
+ local output exit_code
+ output=$(SSH_FAIL_ONCE_PATTERN="cache:clear" bash "${PROJECT_ROOT}/bin/deploy-all.sh" 2>&1)
+ exit_code=$?
+
+ assert_equals "1" "$exit_code" "un échec de déploiement doit faire sortir en erreur"
+ assert_contains "$(cat "$CALL_LOG")" "git checkout --force prev1234567"
+ assert_contains "$output" "code restauré (prev123)"
+
+ _teardown_routing_fixture
+}
+
+test_echec_des_migrations_ne_restaure_pas_le_code() {
+ _setup_routing_fixture
+ _write_routing_stubs
+
+ local output exit_code
+ output=$(SSH_FAIL_ONCE_PATTERN="doctrine:migrations:migrate" bash "${PROJECT_ROOT}/bin/deploy-all.sh" 2>&1)
+ exit_code=$?
+
+ assert_equals "1" "$exit_code"
+ if grep -q "git checkout --force prev1234567" "$CALL_LOG"; then
+ fail "pas de restauration du code après un échec de migration (état de la base incertain)"
+ fi
+
+ _teardown_routing_fixture
+}
diff --git a/tests/bash/deploy-nightly-test.sh b/tests/bash/deploy-nightly-test.sh
index 6d32e74c..784d1c22 100644
--- a/tests/bash/deploy-nightly-test.sh
+++ b/tests/bash/deploy-nightly-test.sh
@@ -25,12 +25,13 @@ _setup_fixture() {
# doivent donc pointer explicitement vers les stubs au lieu de compter
# sur $PATH pour les intercepter.
export DEPLOY_NIGHTLY_PHP_BIN="${STUB_BIN}/php"
- export DEPLOY_NIGHTLY_COMPOSER_BIN="${STUB_BIN}/composer"
+ export DEPLOY_NIGHTLY_COMPOSER_PHAR="${STUB_BIN}/composer.phar"
}
_teardown_fixture() {
rm -rf "$FIXTURE_DIR"
- unset DEPLOY_NIGHTLY_PHP_BIN DEPLOY_NIGHTLY_COMPOSER_BIN
+ unset DEPLOY_NIGHTLY_PHP_BIN DEPLOY_NIGHTLY_COMPOSER_PHAR
+ unset STUB_HEAD_SHA STUB_VENDOR_INCOMPLETE STUB_COMPOSER_INSTALL_EXIT STUB_CACHE_CLEAR_EXIT STUB_CACHE_CLEAR_FAIL_ONCE STUB_MIGRATIONS_EXIT
hash -r
}
@@ -43,7 +44,9 @@ echo "git \$*" >> "${CALL_LOG}"
case "\$1" in
fetch) exit 0 ;;
rev-parse)
- echo "${remote_sha}"
+ # HEAD = code réellement en place (peut différer de .deployed-sha) ;
+ # tout le reste (origin/main) = SHA distant.
+ if [[ "\$2" == "HEAD" ]]; then echo "\${STUB_HEAD_SHA:-head0000}"; else echo "${remote_sha}"; fi
exit 0
;;
checkout) exit \${GIT_CHECKOUT_EXIT:-0} ;;
@@ -66,12 +69,42 @@ EOF
chmod +x "${STUB_BIN}/${name}"
}
+# Stub php : sert à la fois pour composer.phar (via PHP CLI explicite, #570) et
+# pour bin/console. Réglable par variables d'environnement exportées par le test :
+# STUB_VENDOR_INCOMPLETE=1|once composer --dry-run affiche son aide (simule le CGI, exit 0) — toujours / au 1er appel seulement
+# STUB_COMPOSER_INSTALL_EXIT code de sortie de "composer install"
+# STUB_CACHE_CLEAR_EXIT / STUB_CACHE_CLEAR_FAIL_ONCE=1 échec de cache:clear (toujours / au 1er appel)
+# STUB_MIGRATIONS_EXIT code de sortie des migrations
+_write_php_stub() {
+ cat > "${STUB_BIN}/php" <> "${CALL_LOG}"
+args="\$*"
+if [[ "\$args" == *"--dry-run"* ]]; then
+ if [[ "\${STUB_VENDOR_INCOMPLETE:-0}" == "1" || ( "\${STUB_VENDOR_INCOMPLETE:-0}" == "once" && \$(grep -c -e "--dry-run" "${CALL_LOG}") -eq 1 ) ]]; then
+ echo "Warning: Composer should be invoked via the CLI version of PHP, not the cgi-fcgi SAPI"
+ echo "Usage: composer [options] command [arguments]"
+ else
+ echo "Nothing to install, update or remove"
+ fi
+ exit 0
+fi
+if [[ "\$args" == *"composer.phar install"* ]]; then exit \${STUB_COMPOSER_INSTALL_EXIT:-0}; fi
+if [[ "\$args" == *"cache:clear"* ]]; then
+ if [[ "\${STUB_CACHE_CLEAR_FAIL_ONCE:-0}" == "1" && \$(grep -c "cache:clear" "${CALL_LOG}") -eq 1 ]]; then exit 1; fi
+ exit \${STUB_CACHE_CLEAR_EXIT:-0}
+fi
+if [[ "\$args" == *"doctrine:migrations:migrate"* ]]; then exit \${STUB_MIGRATIONS_EXIT:-0}; fi
+exit 0
+STUBEOF
+ chmod +x "${STUB_BIN}/php"
+}
+
_write_all_success_stubs() {
local remote_sha="$1"
_write_git_stub "$remote_sha"
- _write_passthrough_stub composer
_write_passthrough_stub bash
- _write_passthrough_stub php
+ _write_php_stub
}
# Délai de préavis à 0 par défaut dans les tests (sinon chaque test attendrait
@@ -90,7 +123,7 @@ test_instance_deja_a_jour_ne_deploie_rien() {
assert_equals "0" "$exit_code" "sortie attendue à 0 sur instance déjà à jour"
assert_file_not_exists "${FIXTURE_DIR}/composer_called"
local composer_calls
- composer_calls=$(grep -c "^composer " "$CALL_LOG" 2>/dev/null); composer_calls=${composer_calls:-0}
+ composer_calls=$(grep -c "composer.phar install" "$CALL_LOG" 2>/dev/null); composer_calls=${composer_calls:-0}
assert_equals "0" "$composer_calls" "composer ne doit pas être appelé si rien à déployer"
assert_contains "$(cat "${FIXTURE_DIR}/report.log")" "yannick|skipped"
@@ -123,7 +156,7 @@ test_sha_different_execute_les_etapes_dans_lordre() {
calls="$(cat "$CALL_LOG")"
local checkout_line composer_line cache_line migrations_line
checkout_line=$(grep -n "git checkout" <<< "$calls" | head -1 | cut -d: -f1)
- composer_line=$(grep -n "^composer install" <<< "$calls" | head -1 | cut -d: -f1)
+ composer_line=$(grep -n "composer.phar install" <<< "$calls" | head -1 | cut -d: -f1)
if [[ -z "$checkout_line" || -z "$composer_line" ]]; then
fail "étapes attendues absentes de l'historique d'appels"
@@ -138,9 +171,9 @@ test_echec_conserve_le_sha_precedent_et_stoppe_la_chaine() {
_setup_fixture
echo "old0000" > "${FIXTURE_DIR}/instance/.deployed-sha"
_write_git_stub "new1111"
- _write_passthrough_stub composer 1 # composer install échoue
_write_passthrough_stub bash
- _write_passthrough_stub php
+ _write_php_stub
+ export STUB_COMPOSER_INSTALL_EXIT=1 # composer install échoue
/usr/bin/bash "$DEPLOY_NIGHTLY_SCRIPT" yannick "${FIXTURE_DIR}/instance" "${FIXTURE_DIR}/report.log" > /dev/null 2>&1
local exit_code=$?
@@ -150,9 +183,9 @@ test_echec_conserve_le_sha_precedent_et_stoppe_la_chaine() {
assert_contains "$(cat "${FIXTURE_DIR}/report.log")" "yannick|failed|composer install"
# Aucune étape après composer install ne doit avoir été appelée
- local php_calls
- php_calls=$(grep -c "^php " "$CALL_LOG" 2>/dev/null); php_calls=${php_calls:-0}
- assert_equals "0" "$php_calls" "les étapes après l'échec ne doivent pas s'exécuter"
+ local console_calls
+ console_calls=$(grep -c "bin/console" "$CALL_LOG" 2>/dev/null); console_calls=${console_calls:-0}
+ assert_equals "0" "$console_calls" "les étapes après l'échec ne doivent pas s'exécuter"
_teardown_fixture
}
@@ -183,7 +216,7 @@ test_activite_recente_reporte_le_deploiement() {
assert_equals "0" "$exit_code" "sortie attendue à 0 sur report d'activité"
assert_contains "$(cat "${FIXTURE_DIR}/report.log")" "yannick|postponed"
local composer_calls
- composer_calls=$(grep -c "^composer " "$CALL_LOG" 2>/dev/null); composer_calls=${composer_calls:-0}
+ composer_calls=$(grep -c "composer.phar install" "$CALL_LOG" 2>/dev/null); composer_calls=${composer_calls:-0}
assert_equals "0" "$composer_calls" "composer ne doit pas être appelé si activité récente détectée"
assert_equals "" "$(cat "${FIXTURE_DIR}/instance/.deployed-sha" 2>/dev/null)" ".deployed-sha ne doit pas changer sur un report"
@@ -293,8 +326,131 @@ EOF
assert_contains "$(cat "${FIXTURE_DIR}/report.log")" "yannick|postponed"
local composer_calls
- composer_calls=$(grep -c "^composer " "$CALL_LOG" 2>/dev/null); composer_calls=${composer_calls:-0}
+ composer_calls=$(grep -c "composer.phar install" "$CALL_LOG" 2>/dev/null); composer_calls=${composer_calls:-0}
assert_equals "0" "$composer_calls" "composer ne doit pas être appelé si reconnexion pendant le préavis"
_teardown_fixture
}
+
+# ── Composer via PHP CLI explicite, jamais « nu » (#570) ────────────────────
+# Incident du 2026-10-02 : composer lancé via le PHP CGI du PATH cron affichait
+# son aide et sortait en 0 ; vendor/ restait incomplet → 6 instances en 500.
+
+_report() { cat "${FIXTURE_DIR}/report.log" 2>/dev/null; }
+_count_calls() { local n; n=$(grep -c -e "$1" "$CALL_LOG" 2>/dev/null); echo "${n:-0}"; }
+_run_nightly() {
+ /usr/bin/bash "$DEPLOY_NIGHTLY_SCRIPT" yannick "${FIXTURE_DIR}/instance" "${FIXTURE_DIR}/report.log" > /dev/null 2>&1
+}
+
+test_composer_est_lance_via_php_cli_explicite() {
+ _setup_fixture
+ _write_all_success_stubs "new1111"
+
+ _run_nightly
+
+ assert_contains "$(cat "$CALL_LOG")" "composer.phar install --no-interaction"
+ assert_equals "0" "$(_count_calls '^composer ')" "aucun appel direct à « composer »"
+
+ _teardown_fixture
+}
+
+test_vendor_incomplet_apres_composer_fait_echouer_et_restaure() {
+ _setup_fixture
+ echo "old0000" > "${FIXTURE_DIR}/instance/.deployed-sha"
+ _write_all_success_stubs "new1111"
+ export STUB_HEAD_SHA="old0000" STUB_VENDOR_INCOMPLETE="once"
+
+ _run_nightly
+ local exit_code=$?
+
+ assert_equals "1" "$exit_code" "composer qui n'installe rien ne doit pas passer pour un succès"
+ assert_contains "$(_report)" "yannick|failed|vérification de vendor/"
+ assert_contains "$(_report)" "code restauré (old0000)"
+ assert_equals "old0000" "$(cat "${FIXTURE_DIR}/instance/.deployed-sha")"
+ # Seul le cache:clear de la restauration a tourné : celui du déploiement
+ # n'a jamais été atteint.
+ assert_equals "1" "$(_count_calls 'cache:clear')" "cache:clear du déploiement ne doit pas être atteint"
+
+ _teardown_fixture
+}
+
+# ── Rollback (#570) ─────────────────────────────────────────────────────────
+
+test_echec_avant_migrations_restaure_le_code_precedent() {
+ _setup_fixture
+ echo "old0000" > "${FIXTURE_DIR}/instance/.deployed-sha"
+ _write_all_success_stubs "new1111"
+ export STUB_HEAD_SHA="old0000" STUB_CACHE_CLEAR_FAIL_ONCE=1
+
+ _run_nightly
+ local exit_code=$?
+
+ assert_equals "1" "$exit_code"
+ local calls checkout_new checkout_old
+ calls="$(cat "$CALL_LOG")"
+ checkout_new=$(grep -n "git checkout --force new1111" <<< "$calls" | head -1 | cut -d: -f1)
+ checkout_old=$(grep -n "git checkout --force old0000" <<< "$calls" | head -1 | cut -d: -f1)
+ if [[ -z "$checkout_new" || -z "$checkout_old" || "$checkout_old" -le "$checkout_new" ]]; then
+ fail "le code précédent doit être restauré après le checkout du nouveau (new=${checkout_new}, old=${checkout_old})"
+ fi
+ assert_equals "2" "$(_count_calls 'composer.phar install --no-interaction')" "vendor/ réinstallé pour l'ancien lock"
+ assert_equals "2" "$(_count_calls 'cache:clear')" "cache vidé à nouveau après restauration"
+ assert_contains "$(_report)" "yannick|failed|cache:clear"
+ assert_contains "$(_report)" "code restauré (old0000)"
+ assert_equals "old0000" "$(cat "${FIXTURE_DIR}/instance/.deployed-sha")"
+
+ _teardown_fixture
+}
+
+test_rollback_en_echec_est_signale_sans_ambiguite() {
+ _setup_fixture
+ echo "old0000" > "${FIXTURE_DIR}/instance/.deployed-sha"
+ _write_all_success_stubs "new1111"
+ export STUB_HEAD_SHA="old0000" STUB_CACHE_CLEAR_EXIT=1
+
+ _run_nightly
+ local exit_code=$?
+
+ assert_equals "1" "$exit_code"
+ assert_contains "$(_report)" "ROLLBACK ÉCHOUÉ"
+ assert_equals "old0000" "$(cat "${FIXTURE_DIR}/instance/.deployed-sha")"
+
+ _teardown_fixture
+}
+
+# Migrations en échec : l'état de la base est incertain (migration partielle),
+# restaurer l'ancien code sur un schéma à moitié migré serait pire.
+test_echec_des_migrations_ne_restaure_pas_le_code() {
+ _setup_fixture
+ echo "old0000" > "${FIXTURE_DIR}/instance/.deployed-sha"
+ _write_all_success_stubs "new1111"
+ export STUB_HEAD_SHA="old0000" STUB_MIGRATIONS_EXIT=1
+
+ _run_nightly
+ local exit_code=$?
+
+ assert_equals "1" "$exit_code"
+ assert_equals "1" "$(_count_calls 'git checkout --force')" "pas de restauration du code après un échec de migration"
+ assert_contains "$(_report)" "yannick|failed|migrations"
+ assert_equals "old0000" "$(cat "${FIXTURE_DIR}/instance/.deployed-sha")"
+
+ _teardown_fixture
+}
+
+# HEAD déjà sur la cible (cas de l'incident : code nouveau, vendor ancien, lors
+# d'une nouvelle tentative) : rien à restaurer, un second checkout n'aiderait pas.
+test_pas_de_rollback_si_le_code_en_place_est_deja_la_cible() {
+ _setup_fixture
+ echo "old0000" > "${FIXTURE_DIR}/instance/.deployed-sha"
+ _write_all_success_stubs "new1111"
+ export STUB_HEAD_SHA="new1111" STUB_CACHE_CLEAR_EXIT=1
+
+ _run_nightly
+ local exit_code=$?
+
+ assert_equals "1" "$exit_code"
+ assert_equals "1" "$(_count_calls 'git checkout --force')"
+ assert_equals "0" "$(grep -c 'ROLLBACK\|restauré' "${FIXTURE_DIR}/report.log")" "aucune mention de rollback"
+
+ _teardown_fixture
+}
diff --git a/tests/bash/deploy-scripts-test.sh b/tests/bash/deploy-scripts-test.sh
new file mode 100644
index 00000000..e8bca2ef
--- /dev/null
+++ b/tests/bash/deploy-scripts-test.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# =============================================================================
+# Garde-fou transversal aux scripts de déploiement (#570) : composer ne doit
+# jamais être appelé « nu ». Incident du 2026-10-02 : lancé via le PHP CGI du
+# PATH cron, composer affichait son aide et sortait en 0 sans rien installer.
+# Le correctif de la veille n'avait été appliqué qu'à un des trois scripts.
+# =============================================================================
+
+test_aucun_composer_nu_dans_les_scripts_de_deploiement() {
+ local bare
+ bare=$(grep -nE 'COMPOSER_BIN="composer"|(&&|;)[[:space:]]*composer[[:space:]]+install' \
+ "${PROJECT_ROOT}"/bin/*.sh "${PROJECT_ROOT}"/bin/lib/*.sh 2>/dev/null | grep -v '^[^:]*:[0-9]*:[[:space:]]*#')
+ assert_equals "" "$bare" "composer ne doit jamais être appelé sans PHP CLI explicite"
+}
+
+test_les_trois_scripts_partagent_la_definition_commune() {
+ local script
+ for script in deploy-nightly.sh deploy-all.sh deploy.sh; do
+ assert_contains "$(cat "${PROJECT_ROOT}/bin/${script}")" "lib/deploy-common.sh"
+ done
+}