[ADD] script forgejo: installer une forge git en option cochable

Une case au déploiement, et une forge git auto-hébergée répond sur le port 3000,
git par SSH sur 2222. Le travail vit dans un script dédié, appelable seul sur
une machine existante : une seule autorité pour les deux usages.

Le binaire officiel est statique, donc le même fichier sert apt, dnf, pacman et
zypper — c'est ce qui rend l'option portable sans une branche par distribution.
Les architectures suivent l'amont (amd64, arm64, arm-6) ; la case se grise sur
s390x plutôt que de poser un binaire inexécutable. Les quatre secrets sont
écrits par le script : sans oauth2.JWT_SECRET, Forgejo tente de les persister
lui-même et boucle sur un app.ini qu'il n'a pas le droit d'écrire.

Vérifié sur une VM : somme de contrôle validée, service actif, API qui répond,
dépôt créé puis cloné par git, et relance en 1,5 s sans rien réécrire.

--- EN ---

One checkbox at deploy time, and a self-hosted git forge answers on port 3000,
git over SSH on 2222. The work lives in a dedicated script, callable on its own
for an existing machine: one authority for both uses.

The official binary is static, so the same file serves apt, dnf, pacman and
zypper — that is what makes the option portable without a branch per
distribution. Architectures follow upstream (amd64, arm64, arm-6); the checkbox
greys out on s390x rather than dropping a binary that cannot run. The script
writes all four secrets itself: without oauth2.JWT_SECRET, Forgejo tries to
persist them and loops on an app.ini it is not allowed to write.

Verified on a VM: checksum validated, service active, API answering, a repo
created then cloned over git, and a replay in 1.5 s rewriting nothing.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-19 19:41:39 -04:00
parent 69431e17c9
commit 952c92b52a
8 changed files with 728 additions and 17 deletions

301
script/forgejo/install_forgejo.sh Executable file
View file

@ -0,0 +1,301 @@
#!/usr/bin/env bash
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
#
# Installe Forgejo — https://forgejo.org — depuis le binaire statique officiel
# publié sur https://codeberg.org/forgejo/forgejo.
#
# Le binaire est statique et sans dépendance : le même fichier sert Debian,
# Ubuntu, AlmaLinux, Rocky, openSUSE et Arch. Ce script ne touche donc à AUCUN
# gestionnaire de paquets — c'est ce qui le rend portable sur les plateformes
# ERPLibre sans une branche par distribution.
#
# Il n'appelle PAS env_var.sh, à la différence des scripts d'installation
# ERPLibre : Forgejo ne dépend ni du dépôt ni de son venv, et le script doit
# rester utilisable hors d'un checkout.
#
# Réglages, tous par variables d'environnement :
# FORGEJO_VERSION version à poser (défaut : la dernière publiée)
# FORGEJO_HTTP_PORT port web (défaut : 3000)
# FORGEJO_SSH_PORT port SSH interne de Forgejo (défaut : 2222)
# FORGEJO_ADMIN_USER compte administrateur créé (défaut : erplibre)
# FORGEJO_ADMIN_PASSWORD son mot de passe (défaut : erplibre)
# FORGEJO_ADMIN_EMAIL son courriel (défaut : admin@erplibre.local)
# FORGEJO_USER compte système propriétaire (défaut : git)
# FORGEJO_SKIP_ADMIN à 1, ne crée aucun compte (installateur web)
set -euo pipefail
Red='\033[0;31m'
Green='\033[0;32m'
Yellow='\033[0;33m'
Color_Off='\033[0m'
VERSION="${FORGEJO_VERSION:-}"
HTTP_PORT="${FORGEJO_HTTP_PORT:-3000}"
SSH_PORT="${FORGEJO_SSH_PORT:-2222}"
# « admin » est REFUSÉ par Forgejo — « CreateUser: name is reserved », mesuré.
# La liste des noms réservés couvre aussi api, assets, avatars, explore, user…
ADMIN_USER="${FORGEJO_ADMIN_USER:-erplibre}"
ADMIN_PASSWORD="${FORGEJO_ADMIN_PASSWORD:-erplibre}"
ADMIN_EMAIL="${FORGEJO_ADMIN_EMAIL:-admin@erplibre.local}"
RUN_USER="${FORGEJO_USER:-git}"
SKIP_ADMIN="${FORGEJO_SKIP_ADMIN:-0}"
BIN=/usr/local/bin/forgejo
CONF_DIR=/etc/forgejo
CONF="$CONF_DIR/app.ini"
DATA=/var/lib/forgejo
UNIT=/etc/systemd/system/forgejo.service
API=https://codeberg.org/api/v1/repos/forgejo/forgejo/releases
DL=https://codeberg.org/forgejo/forgejo/releases/download
usage() {
sed -n '5,26p' "$0" | sed 's/^# \?//'
exit 0
}
case "${1:-}" in
-h|--help) usage ;;
esac
say() { echo -e " $*"; }
die() { echo -e " ${Red}✗ $*${Color_Off}" >&2; exit 1; }
# --- 1. Architecture -------------------------------------------------------
# Forgejo publie amd64, arm64 et arm-6. PAS de s390x : sur cette architecture
# il faudrait le bâtir depuis les sources en Go, ce que ce script ne fait pas —
# il le dit plutôt que de télécharger un binaire qui ne s'exécutera pas.
case "$(uname -m)" in
x86_64) ARCH=amd64 ;;
aarch64|arm64) ARCH=arm64 ;;
armv6l|armv7l) ARCH=arm-6 ;;
*) die "Forgejo ne publie pas de binaire pour $(uname -m)" \
"(amd64, arm64 et arm-6 seulement)." ;;
esac
# --- 2. Version ------------------------------------------------------------
if [ -z "$VERSION" ]; then
# La liste des versions est en JSON : on la lit avec python3, présent dans
# toutes les images cloud visées. Sans lui, on retombe sur grep — mieux
# qu'un abandon, et le motif est celui d'un champ JSON, pas d'une page web.
if command -v python3 >/dev/null 2>&1; then
VERSION=$(curl -fsSL --max-time 30 "$API?limit=1" 2>/dev/null \
| python3 -c 'import json,sys
try:
print(json.load(sys.stdin)[0]["tag_name"].lstrip("v"))
except Exception:
pass' || true)
else
VERSION=$(curl -fsSL --max-time 30 "$API?limit=1" 2>/dev/null \
| grep -o '"tag_name":"v[^"]*"' | head -1 \
| sed 's/.*"v//;s/"//' || true)
fi
fi
[ -n "$VERSION" ] || die "Version de Forgejo introuvable (réseau ? $API)"
say "Forgejo $VERSION pour $ARCH"
# --- 3. Déjà posé ? --------------------------------------------------------
# Rejouer une installation est le cas normal. Comparer la version évite de
# retélécharger 34 Mo pour rien, et de redémarrer un service qui va bien.
if [ -x "$BIN" ] && "$BIN" --version 2>/dev/null | grep -q "version $VERSION"; then
say "${Green}binaire déjà en version $VERSION, téléchargement évité${Color_Off}"
else
# L'archive .xz pèse 34 Mo contre 114 Mo pour le binaire nu. On la prend
# quand xz est là, sans jamais l'installer : le binaire nu est le repli.
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
if command -v xz >/dev/null 2>&1; then
asset="forgejo-$VERSION-linux-$ARCH.xz"
else
asset="forgejo-$VERSION-linux-$ARCH"
fi
say "téléchargement de $asset"
curl -fsSL --retry 3 --max-time 900 "$DL/v$VERSION/$asset" \
-o "$tmp/$asset" || die "téléchargement impossible : $DL/v$VERSION/$asset"
# Somme de contrôle publiée à côté du fichier : une archive tronquée par une
# coupure réseau donne un binaire qui ne s'exécute pas, et l'erreur arrive
# alors dix étapes plus loin.
if curl -fsSL --max-time 60 "$DL/v$VERSION/$asset.sha256" \
-o "$tmp/$asset.sha256" 2>/dev/null; then
(cd "$tmp" && sha256sum -c "$asset.sha256" >/dev/null) \
|| die "somme de contrôle invalide pour $asset"
say "somme de contrôle vérifiée"
else
say "${Yellow}⚠ somme de contrôle indisponible, non vérifiée${Color_Off}"
fi
case "$asset" in
*.xz) xz -d "$tmp/$asset"; src="$tmp/${asset%.xz}" ;;
*) src="$tmp/$asset" ;;
esac
chmod +x "$src"
sudo install -m 0755 "$src" "$BIN"
say "${Green}binaire posé : $BIN${Color_Off}"
rm -rf "$tmp"
trap - EXIT
fi
# --- 4. Compte système et répertoires --------------------------------------
if ! id "$RUN_USER" >/dev/null 2>&1; then
sudo useradd --system --create-home --home-dir "/home/$RUN_USER" \
--shell /bin/bash --comment "Forgejo" "$RUN_USER"
say "compte système créé : $RUN_USER"
fi
sudo mkdir -p "$DATA"/{custom,data,log} "$CONF_DIR"
sudo chown -R "$RUN_USER:$RUN_USER" "$DATA"
sudo chmod 750 "$DATA"
# Le fichier de configuration appartient à root et se LIT par le groupe : le
# service en a besoin, et Forgejo y écrit ses secrets au premier démarrage si
# on ne les pose pas soi-même — ce que fait l'étape suivante.
sudo chown root:"$RUN_USER" "$CONF_DIR"
sudo chmod 770 "$CONF_DIR"
# --- 5. Configuration ------------------------------------------------------
# JAMAIS réécrite si elle existe : elle porte les secrets, et un utilisateur a
# pu l'ajuster. C'est aussi ce qui rend ce script rejouable.
# « sudo test », et non « [ -f ] » : /etc/forgejo appartient à root:git en 770,
# donc l'utilisateur qui lance le script ne peut même pas y statuer un fichier.
# Le test échouait toujours, et CHAQUE passage réécrivait la configuration avec
# des secrets neufs — ce qui invalide les sessions et les jetons existants.
if sudo test -f "$CONF"; then
say "configuration conservée : $CONF"
else
host=$(hostname -I 2>/dev/null | awk '{print $1}')
[ -n "$host" ] || host=localhost
# Les QUATRE secrets, et pas seulement les deux évidents. Vécu : sans
# « oauth2.JWT_SECRET », Forgejo tente de l'écrire dans app.ini au
# démarrage, n'y arrive pas — le fichier appartient à root — et s'arrête
# sur « [F] save oauth2.JWT_SECRET failed ». Le service redémarrait en
# boucle, 25 fois, sans jamais écouter le port.
#
# Les poser ici garde app.ini NON inscriptible par le service : c'est la
# bonne posture, et ça évite un fichier de configuration qui se réécrit
# tout seul.
secret=$("$BIN" generate secret SECRET_KEY)
token=$("$BIN" generate secret INTERNAL_TOKEN)
jwt=$("$BIN" generate secret JWT_SECRET)
lfs_jwt=$("$BIN" generate secret JWT_SECRET)
# « INSTALL_LOCK = true » verrouille l'installateur web : la machine est
# utilisable sans passer par un formulaire, ce qui est tout l'intérêt d'une
# option cochée au déploiement. SQLite, pour ne pas disputer PostgreSQL à
# Odoo, qui vit sur la même VM.
sudo tee "$CONF" >/dev/null <<CONFEOF
APP_NAME = ERPLibre Forgejo
RUN_USER = $RUN_USER
RUN_MODE = prod
WORK_PATH = $DATA
[server]
PROTOCOL = http
DOMAIN = $host
HTTP_PORT = $HTTP_PORT
ROOT_URL = http://$host:$HTTP_PORT/
APP_DATA_PATH = $DATA/data
DISABLE_SSH = false
START_SSH_SERVER = true
SSH_DOMAIN = $host
SSH_PORT = $SSH_PORT
SSH_LISTEN_PORT = $SSH_PORT
LFS_START_SERVER = true
LFS_JWT_SECRET = $lfs_jwt
[database]
DB_TYPE = sqlite3
PATH = $DATA/data/forgejo.db
[repository]
ROOT = $DATA/data/forgejo-repositories
[security]
INSTALL_LOCK = true
PASSWORD_COMPLEXITY = off
SECRET_KEY = $secret
INTERNAL_TOKEN = $token
[oauth2]
JWT_SECRET = $jwt
[service]
DISABLE_REGISTRATION = false
REQUIRE_SIGNIN_VIEW = false
[lfs]
PATH = $DATA/data/lfs
[log]
ROOT_PATH = $DATA/log
LEVEL = info
CONFEOF
sudo chown root:"$RUN_USER" "$CONF"
sudo chmod 640 "$CONF"
say "${Green}configuration écrite : $CONF${Color_Off}"
fi
# --- 6. Service ------------------------------------------------------------
sudo tee "$UNIT" >/dev/null <<UNITEOF
[Unit]
Description=Forgejo (Beyond coding. We forge.)
After=network.target network-online.target
[Service]
Type=simple
User=$RUN_USER
Group=$RUN_USER
WorkingDirectory=$DATA
ExecStart=$BIN web --config $CONF
Restart=always
RestartSec=5
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"
# --- 7. Attendre qu'il RÉPONDE --------------------------------------------
# Une requête HTTP, pas un « systemctl is-active » : le service est « active »
# bien avant d'écouter, et « activating » en boucle de redémarrage ressemble à
# un démarrage en cours. /api/v1/version prouve que l'application SERT — la
# création du compte administrateur qui suit a besoin de la base migrée.
#
# Et surtout pas « exec 3<>/dev/tcp/... » : « exec » est un builtin spécial, et
# une redirection qui échoue termine le shell. Le script mourait donc en
# 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 \
"http://127.0.0.1:$HTTP_PORT/api/v1/version"; then
ready=1
break
fi
sleep 2
done
[ "$ready" = 1 ] || die "aucune réponse sur le port $HTTP_PORT après 120 s" \
"(sudo journalctl -u forgejo -n 40)"
# --- 8. Compte administrateur ---------------------------------------------
# Créé seulement s'il n'y a AUCUN compte : rejouer le script ne doit pas
# échouer sur « user already exists », ni écraser un mot de passe choisi.
if [ "$SKIP_ADMIN" = 1 ]; then
say "aucun compte créé (FORGEJO_SKIP_ADMIN=1)"
elif sudo -u "$RUN_USER" "$BIN" admin user list --config "$CONF" 2>/dev/null \
| tail -n +2 | grep -q .; then
say "comptes déjà présents, administrateur non recréé"
else
sudo -u "$RUN_USER" "$BIN" admin user create --admin \
--username "$ADMIN_USER" --password "$ADMIN_PASSWORD" \
--email "$ADMIN_EMAIL" --must-change-password=false \
--config "$CONF" >/dev/null \
|| die "création de l'administrateur impossible"
say "${Green}administrateur créé : $ADMIN_USER / $ADMIN_PASSWORD${Color_Off}"
fi
# --- 9. Résumé -------------------------------------------------------------
host=$(hostname -I 2>/dev/null | awk '{print $1}')
[ -n "$host" ] || host=localhost
version=$("$BIN" --version 2>/dev/null | head -1)
say "${Green}Forgejo prêt${Color_Off} : http://$host:$HTTP_PORT/"
say " $version"
say " git par SSH : port $SSH_PORT (serveur interne de Forgejo)"
say " service : sudo systemctl status forgejo"
say " journal : sudo journalctl -u forgejo -f"

View file

@ -337,6 +337,25 @@ Rendering is `swangle` in the AVD's own `config.ini` — `auto` would open a
black screen, and `swiftshader_indirect` no longer exists, the emulator
answering `Selected GPU option ... is not valid`.
A sixth, **Forgejo**, installs a self-hosted git forge — the software behind
Codeberg — from the project's official static binary, and leaves it serving on
port 3000 with git-over-SSH on 2222. Like the mobile build it needs no desktop,
and unlike it no package family is excluded: the binary is static, so the same
file serves apt, dnf, pacman and zypper. That is what makes it portable across
the ERPLibre platforms without a branch per distribution. Architectures follow
upstream, which publishes amd64, arm64 and arm-6 — the checkbox greys out on
s390x rather than dropping a binary that cannot run.
The work lives in `script/forgejo/install_forgejo.sh`, callable on its own for
an existing machine: `./script/forgejo/install_forgejo.sh`. It verifies the
published checksum, writes all four secrets itself so the service never needs
to rewrite its own configuration, and stores its data in SQLite so it does not
dispute PostgreSQL with Odoo on the same VM. Replaying it is cheap and safe —
1.5 s measured with everything in place: it skips a binary already at the right
version, never overwrites an existing `app.ini`, and does not recreate the
administrator. `FORGEJO_VERSION`, `FORGEJO_HTTP_PORT`, `FORGEJO_ADMIN_USER` and
a few others tune it; `--help` lists them.
Each tool is filtered per VM — by architecture, desktop flavour and package
family — and its disk cost is added to the plan before anything is created.
@ -483,6 +502,26 @@ Le rendu est « swangle » dans le `config.ini` de l'AVD — « auto » ouvrirai
écran noir, et « swiftshader_indirect » n'existe plus, l'émulateur répondant
`Selected GPU option ... is not valid`.
Un sixième, **Forgejo**, installe une forge git auto-hébergée — le logiciel
derrière Codeberg — depuis le binaire statique officiel du projet, et la laisse
en service sur le port 3000, avec git par SSH sur 2222. Comme la compilation
mobile, elle n'a besoin d'aucun bureau ; contrairement à elle, aucune famille de
paquets n'est exclue : le binaire est statique, donc le même fichier sert apt,
dnf, pacman et zypper. C'est ce qui la rend portable sur les plateformes
ERPLibre sans une branche par distribution. Les architectures suivent l'amont,
qui publie amd64, arm64 et arm-6 — la case se grise sur s390x plutôt que de
poser un binaire qui ne s'exécutera pas.
Le travail vit dans `script/forgejo/install_forgejo.sh`, appelable seul sur une
machine existante : `./script/forgejo/install_forgejo.sh`. Il vérifie la somme
de contrôle publiée, écrit lui-même les quatre secrets pour que le service n'ait
jamais à réécrire sa propre configuration, et garde ses données en SQLite pour
ne pas disputer PostgreSQL à Odoo sur la même VM. Le rejouer est sans risque et
bon marché — 1,5 s mesuré, tout étant en place : il saute un binaire déjà à la
bonne version, ne réécrit jamais un `app.ini` existant et ne recrée pas
l'administrateur. `FORGEJO_VERSION`, `FORGEJO_HTTP_PORT`, `FORGEJO_ADMIN_USER`
et quelques autres le règlent ; `--help` les énumère.
Chaque outil est filtré VM par VM — architecture, saveur de bureau et famille
de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé.

View file

@ -228,6 +228,26 @@ Le rendu est « swangle » dans le `config.ini` de l'AVD — « auto » ouvrirai
écran noir, et « swiftshader_indirect » n'existe plus, l'émulateur répondant
`Selected GPU option ... is not valid`.
Un sixième, **Forgejo**, installe une forge git auto-hébergée — le logiciel
derrière Codeberg — depuis le binaire statique officiel du projet, et la laisse
en service sur le port 3000, avec git par SSH sur 2222. Comme la compilation
mobile, elle n'a besoin d'aucun bureau ; contrairement à elle, aucune famille de
paquets n'est exclue : le binaire est statique, donc le même fichier sert apt,
dnf, pacman et zypper. C'est ce qui la rend portable sur les plateformes
ERPLibre sans une branche par distribution. Les architectures suivent l'amont,
qui publie amd64, arm64 et arm-6 — la case se grise sur s390x plutôt que de
poser un binaire qui ne s'exécutera pas.
Le travail vit dans `script/forgejo/install_forgejo.sh`, appelable seul sur une
machine existante : `./script/forgejo/install_forgejo.sh`. Il vérifie la somme
de contrôle publiée, écrit lui-même les quatre secrets pour que le service n'ait
jamais à réécrire sa propre configuration, et garde ses données en SQLite pour
ne pas disputer PostgreSQL à Odoo sur la même VM. Le rejouer est sans risque et
bon marché — 1,5 s mesuré, tout étant en place : il saute un binaire déjà à la
bonne version, ne réécrit jamais un `app.ini` existant et ne recrée pas
l'administrateur. `FORGEJO_VERSION`, `FORGEJO_HTTP_PORT`, `FORGEJO_ADMIN_USER`
et quelques autres le règlent ; `--help` les énumère.
Chaque outil est filtré VM par VM — architecture, saveur de bureau et famille
de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé.

View file

@ -211,6 +211,25 @@ Rendering is `swangle` in the AVD's own `config.ini` — `auto` would open a
black screen, and `swiftshader_indirect` no longer exists, the emulator
answering `Selected GPU option ... is not valid`.
A sixth, **Forgejo**, installs a self-hosted git forge — the software behind
Codeberg — from the project's official static binary, and leaves it serving on
port 3000 with git-over-SSH on 2222. Like the mobile build it needs no desktop,
and unlike it no package family is excluded: the binary is static, so the same
file serves apt, dnf, pacman and zypper. That is what makes it portable across
the ERPLibre platforms without a branch per distribution. Architectures follow
upstream, which publishes amd64, arm64 and arm-6 — the checkbox greys out on
s390x rather than dropping a binary that cannot run.
The work lives in `script/forgejo/install_forgejo.sh`, callable on its own for
an existing machine: `./script/forgejo/install_forgejo.sh`. It verifies the
published checksum, writes all four secrets itself so the service never needs
to rewrite its own configuration, and stores its data in SQLite so it does not
dispute PostgreSQL with Odoo on the same VM. Replaying it is cheap and safe —
1.5 s measured with everything in place: it skips a binary already at the right
version, never overwrites an existing `app.ini`, and does not recreate the
administrator. `FORGEJO_VERSION`, `FORGEJO_HTTP_PORT`, `FORGEJO_ADMIN_USER` and
a few others tune it; `--help` lists them.
Each tool is filtered per VM — by architecture, desktop flavour and package
family — and its disk cost is added to the plan before anything is created.

View file

@ -5422,6 +5422,32 @@ class TODO:
# manifeste ajoute, et du venv d'outils pour le synchroniser.
"phase": "after",
},
# Forgejo est un SERVICE, pas un outil de bureau : une VM serveur le
# prend aussi bien qu'une VM graphique. Son binaire est STATIQUE — le
# même fichier sur apt, dnf, pacman et zypper — donc aucune famille de
# paquets n'est exclue, et c'est ce qui le rend portable sur toutes les
# plateformes ERPLibre sans une branche par distribution.
#
# Les architectures, elles, sont bornées par l'amont : Forgejo publie
# amd64, arm64 et arm-6, et RIEN pour s390x. Sur celle-là il faudrait le
# bâtir en Go ; la case se grise plutôt que de poser un binaire qui ne
# s'exécute pas.
#
# Disque : ~115 Mo de binaire (34 Mo téléchargés en .xz), la base SQLite
# et les dépôts que l'utilisateur y poussera.
"forgejo": {
"label": "Forgejo (git forge)",
"hint": "self-hosted git forge on :3000, SQLite",
"disk_gb": 2,
"arches": ("amd64", "arm64"),
"desktops": (),
"needs_desktop": False,
"families": (),
# APRÈS l'installation : le script vit dans le dépôt, donc après le
# clone. Rien d'autre ne l'y oblige — Forgejo ne dépend ni du venv
# ni d'Odoo.
"phase": "after",
},
# L'émulateur n'a pas besoin de bureau DANS la VM : il s'affiche sur
# l'écran de qui s'y connecte, par « ssh -X ». Il a besoin, lui, de KVM
# dans la VM — donc de virtualisation imbriquée sur l'hôte, ce que le
@ -6474,6 +6500,25 @@ class TODO:
+ f'echo " {t("smoother, without X11:")} TODO > Execute > Deploy > QEMU/KVM > tunnel > 4"'
)
def _qemu_forgejo_steps(self, el_dir):
"""Pose Forgejo dans la VM, par le script dédié du dépôt.
Tout le travail est DANS le script — architecture, version, somme de
contrôle, compte système, configuration, service, compte
administrateur. Ce bloc ne fait que l'appeler : une seule autorité, et
la même commande sert un déploiement de VM et une installation à la
main sur une machine existante.
Pas de garde, comme la compilation mobile : une VM dont la forge
demandée n'existe pas n'est pas la VM demandée. Le script, lui, est
rejouable — il ne retélécharge pas un binaire déjà en place et ne
réécrit jamais une configuration existante.
"""
return (
f'echo "== {t("Forgejo (git forge)")} =="; '
f"{el_dir}/script/forgejo/install_forgejo.sh"
)
def _qemu_after_remote_cmd(self, tools, prod=False):
"""Phase d'APRÈS l'installation : prologue commun, SDK commun, puis ce
qui a été coché.
@ -6486,29 +6531,39 @@ class TODO:
émulateur créé avec succès effacerait le verdict de la compilation."""
picked = [
k
for k in ("mobile", "avd")
for k in ("forgejo", "mobile", "avd")
if k in (tools or ()) and k in self._QEMU_VM_TOOLS
]
if not picked:
return ""
el_dir = self._qemu_install_dir(prod)
parts = []
# Forgejo d'abord : une minute, contre une heure pour le SDK et l'APK.
# Un échec rapide se voit tôt plutôt qu'après le long.
if "forgejo" in picked:
parts.append(f"{{ {self._qemu_forgejo_steps(el_dir)}; }}")
groups = []
if "mobile" in picked:
groups.append(self._qemu_mobile_build_steps(el_dir))
if "avd" in picked:
groups.append(self._qemu_avd_steps(el_dir))
return (
f'echo "== {t("ERPLibre mobile, Android SDK (long)")} =="; '
+ self._qemu_android_prologue_cmd()
+ self._qemu_android_sdk_steps(el_dir)
# Chaque groupe entre ACCOLADES. Sans elles, « && » ne lie que la
# première commande du groupe suivant : mesuré, un APK manquant
# laissait tourner l'émulateur puis rendait 0 — la VM repassait au
# vert alors que rien n'avait compilé. C'est le même piège que le
# bloc de service systemd, quelques centaines de lignes plus haut.
+ " && ".join(f"{{ {g}; }}" for g in groups)
+ "; "
)
if groups:
# UN seul prologue et un seul SDK même quand les deux options le
# sont : deux prologues, et le second tronquerait le journal
# détaillé du premier.
parts.append(
"{ "
+ f'echo "== {t("ERPLibre mobile, Android SDK (long)")} =="; '
+ self._qemu_android_prologue_cmd()
+ self._qemu_android_sdk_steps(el_dir)
# Chaque groupe entre ACCOLADES. Sans elles, « && » ne lie que
# la première commande du groupe suivant : mesuré, un APK
# manquant laissait tourner l'émulateur puis rendait 0 — la VM
# repassait au vert alors que rien n'avait compilé.
+ " && ".join(f"{{ {g}; }}" for g in groups)
+ "; }"
)
return " && ".join(parts) + "; "
def _qemu_mobile_remote_cmd(self, prod=False):
"""Compilation mobile seule — la forme que testent les tests."""

View file

@ -2260,6 +2260,14 @@ TRANSLATIONS = {
"line": {"fr": "ligne", "en": "line"},
"free space": {"fr": "libre", "en": "free"},
"silent": {"fr": "silence", "en": "silent"},
"Forgejo (git forge)": {
"fr": "Forgejo (forge git)",
"en": "Forgejo (git forge)",
},
"self-hosted git forge on :3000, SQLite": {
"fr": "forge git auto-hébergée sur :3000, SQLite",
"en": "self-hosted git forge on :3000, SQLite",
},
"no project yet, second try on the warm caches": {
"fr": "pas de projet encore ; seconde tentative sur les caches chauds",
"en": "no project yet, second try on the warm caches",

View file

@ -99,10 +99,17 @@ class TestToolDisk(unittest.TestCase):
def test_a_server_only_pays_for_what_it_gets(self):
"""Un serveur ne porte aucun IDE, donc il n'en paie pas le disque —
mais il paie bien ce qu'il reçoit : compilation mobile et émulateur."""
mais il paie bien ce qu'il reçoit : compilation mobile, émulateur et
forge. La somme est calculée depuis la table plutôt qu'écrite en
chiffre : ajouter un outil sans écran ne doit pas casser ce test, il
doit le suivre."""
expected = sum(
spec["disk_gb"]
for key, spec in TODO._QEMU_VM_TOOLS.items()
if not spec["needs_desktop"]
)
self.assertEqual(
TODO._QEMU_VM_TOOLS["mobile"]["disk_gb"]
+ TODO._QEMU_VM_TOOLS["avd"]["disk_gb"],
expected,
self.todo._qemu_tools_disk_gb(self.all, "amd64", "", "ubuntu"),
)
@ -278,7 +285,9 @@ class TestMobileBuild(unittest.TestCase):
"""Elle compile, elle n'affiche rien : un bureau serait du gaspillage.
L'émulateur non plus n'en a pas besoin — il s'affiche par ssh -X."""
got = self.todo._qemu_tools_for(self.all, "amd64", "", "ubuntu")
self.assertEqual(["mobile", "avd"], got)
self.assertEqual(["mobile", "forgejo", "avd"], got)
# Forgejo est là pour la même raison que la compilation : un
# service ne demande pas d'écran.
def test_it_is_bounded_to_apt(self):
"""install-android.sh du dépôt mobile commence par « sudo apt install

260
test/test_qemu_forgejo.py Normal file
View file

@ -0,0 +1,260 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Option Forgejo : la case, son filtrage, et le script qui fait le travail.
Forgejo est un service, pas un outil de bureau : une VM serveur le prend comme
une VM graphique. Son binaire est statique, donc le même fichier sert apt, dnf,
pacman et zypper — c'est ce qui le rend portable sans une branche par
distribution. Les architectures, elles, sont bornées par l'amont : Forgejo
publie amd64, arm64 et arm-6, et rien pour s390x.
Le script est vérifié en l'EXÉCUTANT sur ses chemins de refus — architecture
inconnue, version introuvable — qui précèdent toute élévation de privilège et
ne touchent donc à rien.
"""
import os
import pathlib
import subprocess
import sys
import tempfile
import unittest
sys.argv = ["todo.py"]
from script.todo.todo import TODO # noqa: E402
SCRIPT = (
pathlib.Path(__file__).resolve().parent.parent
/ "script/forgejo/install_forgejo.sh"
)
class TestTheCheckbox(unittest.TestCase):
def setUp(self):
self.todo = TODO.__new__(TODO)
self.spec = TODO._QEMU_VM_TOOLS["forgejo"]
def test_it_is_offered_in_the_form(self):
keys = [k for k, _l, _h in TODO._qemu_vm_tool_choices()]
self.assertIn("forgejo", keys)
def test_a_plain_server_gets_it(self):
"""Une forge n'affiche rien : elle n'a pas besoin de bureau."""
self.assertFalse(self.spec["needs_desktop"])
got = self.todo._qemu_tools_for(("forgejo",), "amd64", "", "ubuntu")
self.assertIn("forgejo", got)
def test_every_package_family_gets_it(self):
"""Le binaire est statique : aucune famille n'est exclue, à la
différence de la compilation mobile que son installateur borne à apt.
"""
self.assertEqual(self.spec["families"], ())
for distro in ("ubuntu", "debian", "almalinux", "opensuse", "arch"):
self.assertIn(
"forgejo",
self.todo._qemu_tools_for(("forgejo",), "amd64", "", distro),
distro,
)
def test_arm64_yes_s390x_no(self):
"""Forgejo publie amd64, arm64 et arm-6. Sur s390x il faudrait le bâtir
en Go : la case se grise plutôt que de poser un binaire inexécutable.
"""
for arch in ("amd64", "arm64"):
self.assertIn(
"forgejo",
self.todo._qemu_tools_for(("forgejo",), arch, "", "ubuntu"),
arch,
)
self.assertNotIn(
"forgejo",
self.todo._qemu_tools_for(("forgejo",), "s390x", "", "ubuntu"),
)
def test_its_disk_cost_is_counted_in_the_plan(self):
self.assertGreater(
self.todo._qemu_tools_disk_gb(("forgejo",), "amd64", "", "ubuntu"),
0,
)
class TestTheInstallBlock(unittest.TestCase):
def setUp(self):
self.todo = TODO.__new__(TODO)
def test_it_calls_the_dedicated_script(self):
"""Une seule autorité : la même commande sert le déploiement d'une VM et
une installation à la main."""
for prod, root in (
(False, "$HOME/git/erplibre"),
(True, "/opt/erplibre"),
):
block = self.todo._qemu_forgejo_steps(
self.todo._qemu_install_dir(prod)
)
self.assertIn(f"{root}/script/forgejo/install_forgejo.sh", block)
def test_alone_it_does_not_drag_the_android_prologue(self):
"""Cocher Forgejo seul ne doit pas installer un SDK Android."""
cmd = self.todo._qemu_after_remote_cmd(("forgejo",), False)
self.assertIn("install_forgejo.sh", cmd)
self.assertNotIn("sdkmanager", cmd)
self.assertNotIn("mstep", cmd)
def test_with_the_mobile_build_forgejo_comes_first(self):
"""Une minute contre une heure : un échec rapide se voit tôt."""
cmd = self.todo._qemu_after_remote_cmd(("forgejo", "mobile"), False)
self.assertLess(cmd.index("install_forgejo.sh"), cmd.index("gradlew"))
def test_a_forgejo_failure_still_fails_the_vm(self):
"""Même contrat que la compilation mobile : une VM dont la forge
demandée n'existe pas n'est pas la VM demandée. Les groupes sont donc
liés par « && », jamais par « ; »."""
cmd = self.todo._qemu_after_remote_cmd(("forgejo", "mobile"), False)
between = cmd[
cmd.index("install_forgejo.sh") : cmd.index("ERPLibre mobile")
]
self.assertIn("&&", between)
self.assertNotIn("|| true", between)
def test_valid_shell_in_every_combination(self):
for tools in (
("forgejo",),
("forgejo", "mobile"),
("forgejo", "avd"),
("forgejo", "mobile", "avd"),
):
cmd = self.todo._qemu_after_remote_cmd(tools, False)
res = subprocess.run(
["bash", "-n"],
input="mstep() { :; }; mdiag() { :; };\n" + cmd,
capture_output=True,
text=True,
)
self.assertEqual(0, res.returncode, f"{tools}: {res.stderr}")
class TestTheScript(unittest.TestCase):
"""Le script lui-même, exécuté sur ses chemins de refus."""
def test_it_is_executable_and_valid_shell(self):
self.assertTrue(os.access(SCRIPT, os.X_OK), "pas exécutable")
res = subprocess.run(
["bash", "-n", str(SCRIPT)], capture_output=True, text=True
)
self.assertEqual(0, res.returncode, res.stderr)
def test_help_explains_the_knobs_and_exits_clean(self):
res = subprocess.run(
["bash", str(SCRIPT), "--help"], capture_output=True, text=True
)
self.assertEqual(0, res.returncode, res.stderr)
for knob in (
"FORGEJO_VERSION",
"FORGEJO_HTTP_PORT",
"FORGEJO_ADMIN_USER",
):
self.assertIn(knob, res.stdout)
def _run_with_stubs(self, stubs, env=None):
"""Lance le script avec un PATH bouchonné. Les chemins testés ici
s'arrêtent AVANT tout sudo : rien n'est installé nulle part."""
with tempfile.TemporaryDirectory() as tmp:
bin_dir = pathlib.Path(tmp) / "bin"
bin_dir.mkdir()
for name, body in stubs.items():
(bin_dir / name).write_text(f"#!/bin/bash\n{body}\n")
(bin_dir / name).chmod(0o755)
return subprocess.run(
["bash", str(SCRIPT)],
capture_output=True,
text=True,
env=dict(
os.environ,
PATH=f"{bin_dir}:/usr/bin:/bin",
**(env or {}),
),
timeout=120,
)
def test_an_unpublished_architecture_is_refused_by_name(self):
"""Sur s390x, Forgejo n'a pas de binaire. Le dire vaut mieux que
télécharger un fichier qui ne s'exécutera pas."""
res = self._run_with_stubs({"uname": "echo s390x"})
self.assertNotEqual(0, res.returncode)
self.assertIn("s390x", res.stdout + res.stderr)
def test_an_unreachable_release_feed_is_named(self):
res = self._run_with_stubs({"uname": "echo x86_64", "curl": "exit 7"})
self.assertNotEqual(0, res.returncode)
self.assertIn("Version", res.stdout + res.stderr)
def test_a_pinned_version_needs_no_feed(self):
"""FORGEJO_VERSION évite l'appel réseau : utile hors ligne, et c'est ce
qui rend ce test rapide."""
res = self._run_with_stubs(
{"uname": "echo x86_64", "curl": "exit 7", "sudo": "exit 0"},
env={"FORGEJO_VERSION": "9.9.9"},
)
out = res.stdout + res.stderr
self.assertIn("9.9.9", out)
# Il échoue plus loin (le téléchargement est bouchonné), pas sur la
# version : c'est bien le réseau du flux qui a été évité.
self.assertNotIn("Version de Forgejo introuvable", out)
class TestTheScriptGuards(unittest.TestCase):
"""Quatre pièges rencontrés en le mettant au point, tous mesurés."""
def setUp(self):
self.body = SCRIPT.read_text()
@property
def code_lines(self):
"""Les lignes de CODE : le piège est expliqué en commentaire, et un
test qui cherche dans les commentaires trébuche sur sa propre
documentation — vécu à l'écriture de ce fichier."""
return [
ln
for ln in self.body.splitlines()
if ln.strip() and not ln.lstrip().startswith("#")
]
def test_it_never_probes_with_exec_on_dev_tcp(self):
"""« exec » est un builtin spécial : une redirection qui échoue termine
le shell. Le script mourait au premier tour de la boucle d'attente,
code 1 et pas un mot."""
guilty = [ln for ln in self.code_lines if "exec 3<>" in ln]
self.assertEqual([], guilty)
self.assertIn("/api/v1/version", self.body)
def test_the_config_test_goes_through_sudo(self):
"""/etc/forgejo est en 770 root:git : « [ -f ] » échouait toujours, et
chaque passage réécrivait la configuration avec des secrets neufs."""
self.assertIn("sudo test -f", self.body)
def test_all_four_secrets_are_written(self):
"""Sans oauth2.JWT_SECRET, Forgejo tente de l'écrire dans app.ini,
n'y arrive pas, et boucle sur « [F] save oauth2.JWT_SECRET failed »."""
for key in (
"SECRET_KEY",
"INTERNAL_TOKEN",
"JWT_SECRET",
"LFS_JWT_SECRET",
):
self.assertIn(key, self.body, key)
def test_the_default_admin_name_is_not_reserved(self):
"""Forgejo refuse « admin » : « CreateUser: name is reserved »."""
self.assertIn("FORGEJO_ADMIN_USER:-erplibre", self.body)
self.assertNotIn("FORGEJO_ADMIN_USER:-admin}", self.body)
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"):
self.assertNotIn(pm, self.body, pm)
if __name__ == "__main__":
unittest.main()