[FIX] script forgejo: redémarrer le service quand la configuration change

Tout push finissait sur « Forgejo: Internal Server Error Decoding Failed », et
le message ne désigne pas la cause. Le journal, lui, la donne : 403 sur
/api/internal/hook/pre-receive, refusé par le contrôle du jeton interne. Le
serveur comparait l'INTERNAL_TOKEN qu'il tenait EN MÉMOIRE à celui que le hook
venait de lire sur le disque — deux valeurs différentes — et répondait 403 à son
propre hook, que celui-ci ne sait pas décoder.

La cause est ici : « systemctl enable --now » ne touche pas un service déjà
actif. Le script redémarre donc quand le binaire, la configuration ou l'unité
ont changé, et se tait sinon. Vérifié sur la VM : configuration régénérée
service actif -> redémarrage -> push accepté ; relance sur forge saine ->
aucun redémarrage, push toujours accepté.

--- EN ---

Every push ended on "Forgejo: Internal Server Error Decoding Failed", and the
message does not name the cause. The log does: 403 on
/api/internal/hook/pre-receive, refused by the internal token check. The server
was comparing the INTERNAL_TOKEN it held IN MEMORY with the one the hook had
just read from disk — two different values — and answered 403 to its own hook,
which cannot decode a 403.

The cause is here: "systemctl enable --now" does not touch an already active
service. The script now restarts when the binary, the configuration or the unit
changed, and stays quiet otherwise. Verified on the VM: config regenerated with
the service active -> restart -> push accepted; replay on a healthy forge -> no
restart, push still accepted.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-20 02:04:50 -04:00
parent 1785a2019d
commit 3ec7eb36f1
2 changed files with 69 additions and 5 deletions

View file

@ -41,6 +41,11 @@ ADMIN_EMAIL="${FORGEJO_ADMIN_EMAIL:-admin@erplibre.local}"
RUN_USER="${FORGEJO_USER:-git}"
SKIP_ADMIN="${FORGEJO_SKIP_ADMIN:-0}"
# Ce qui a changé sur le disque pendant ce passage. Le service ne redémarre que
# si quelque chose a bougé : rejouer le script sur une forge saine ne doit pas
# l'interrompre, même deux secondes.
CHANGED=0
BIN=/usr/local/bin/forgejo
CONF_DIR=/etc/forgejo
CONF="$CONF_DIR/app.ini"
@ -152,6 +157,7 @@ else
esac
chmod +x "$src"
sudo install -m 0755 "$src" "$BIN"
CHANGED=1
say "${Green}binaire posé : $BIN${Color_Off}"
rm -rf "$tmp"
trap - EXIT
@ -249,11 +255,13 @@ LEVEL = info
CONFEOF
sudo chown root:"$RUN_USER" "$CONF"
sudo chmod 640 "$CONF"
CHANGED=1
say "${Green}configuration écrite : $CONF${Color_Off}"
fi
# --- 6. Service ------------------------------------------------------------
sudo tee "$UNIT" >/dev/null <<UNITEOF
unit_tmp=$(mktemp)
cat > "$unit_tmp" <<UNITEOF
[Unit]
Description=Forgejo (Beyond coding. We forge.)
After=network.target network-online.target
@ -271,9 +279,29 @@ Environment=USER=$RUN_USER HOME=/home/$RUN_USER GITEA_WORK_DIR=$DATA
[Install]
WantedBy=multi-user.target
UNITEOF
sudo systemctl daemon-reload
sudo systemctl enable --now forgejo.service >/dev/null 2>&1 \
|| die "le service refuse de démarrer : sudo journalctl -u forgejo -n 40"
if ! sudo cmp -s "$unit_tmp" "$UNIT" 2>/dev/null; then
sudo install -m 0644 "$unit_tmp" "$UNIT"
sudo systemctl daemon-reload
CHANGED=1
say "service défini : $UNIT"
fi
rm -f "$unit_tmp"
sudo systemctl enable forgejo.service >/dev/null 2>&1 || true
# « restart » et NON « enable --now » quand quelque chose a changé : « --now »
# ne touche pas à un service déjà actif, qui garde alors sa configuration en
# MÉMOIRE. Vécu, et le symptôme ne désigne pas la cause : le serveur comparait
# son ancien INTERNAL_TOKEN à celui que le hook venait de lire sur le disque, et
# répondait 403 à son propre hook. Tout push finissait sur « Forgejo: Internal
# Server Error Decoding Failed », le hook ne sachant pas décoder un 403.
if [ "$CHANGED" = 1 ]; then
sudo systemctl restart forgejo.service \
|| die "le service refuse de démarrer : sudo journalctl -u forgejo -n 40"
say "service redémarré (configuration ou binaire modifié)"
elif ! systemctl is-active --quiet forgejo.service; then
sudo systemctl start forgejo.service \
|| die "le service refuse de démarrer : sudo journalctl -u forgejo -n 40"
fi
# --- 7. Attendre qu'il RÉPONDE --------------------------------------------
# Une requête HTTP, pas un « systemctl is-active » : le service est « active »
@ -286,7 +314,10 @@ sudo systemctl enable --now forgejo.service >/dev/null 2>&1 \
# silence, au premier tour de la boucle, code 1 sans un mot — vécu.
ready=0
for i in $(seq 1 60); do
if curl -fsS -o /dev/null --max-time 3 \
# « -fs » sans « -S » : dans une boucle de réessai, le message de curl est
# du bruit — « Failed to connect » au premier tour est normal, le service
# vient de redémarrer. C'est le die final qui parle si rien ne répond.
if curl -fs -o /dev/null --max-time 3 \
"http://127.0.0.1:$HTTP_PORT/api/v1/version"; then
ready=1
break

View file

@ -339,6 +339,39 @@ class TestTheScriptGuards(unittest.TestCase):
self.assertIn("FORGEJO_ADMIN_USER:-erplibre", self.body)
self.assertNotIn("FORGEJO_ADMIN_USER:-admin}", self.body)
def test_it_restarts_when_something_changed(self):
"""« enable --now » ne touche PAS un service déjà actif : il garde alors
sa configuration en mémoire. Vécu, et le symptôme ne désignait pas la
cause — le serveur comparait son ancien INTERNAL_TOKEN à celui que le
hook venait de lire, répondait 403 à son propre hook, et tout push
finissait sur « Internal Server Error Decoding Failed »."""
self.assertIn("systemctl restart forgejo.service", self.body)
self.assertNotIn("enable --now forgejo", self.body)
def test_the_restart_is_conditional(self):
"""Rejouer le script sur une forge saine ne doit pas l'interrompre,
même deux secondes."""
self.assertIn("CHANGED=0", self.body)
self.assertIn('[ "$CHANGED" = 1 ]', self.body)
# Trois évènements le lèvent : binaire posé, config écrite, unité
# modifiée.
self.assertEqual(3, self.body.count("CHANGED=1"))
def test_the_unit_is_compared_before_being_written(self):
"""Sans comparaison, l'unité serait réécrite à l'identique et le
service redémarrerait pour rien à chaque passage."""
self.assertIn("cmp -s", self.body)
def test_the_readiness_loop_stays_quiet_while_retrying(self):
"""« Failed to connect » au premier tour est normal — le service vient
de redémarrer. C'est le die final qui parle."""
# La commande est coupée sur deux lignes : on regarde le BLOC de la
# boucle, pas la ligne qui porte l'URL.
start = self.body.index("ready=0")
block = self.body[start : self.body.index('[ "$ready" = 1 ]', start)]
self.assertIn("curl -fs -o /dev/null", block)
self.assertNotIn("-fsS", block)
def test_it_touches_no_package_manager(self):
"""C'est ce qui le rend portable : le binaire est statique."""
for pm in ("apt-get install", "dnf install", "pacman -S", "zypper"):