Mettre en place l'intégration continue (Forgejo Actions) #6

Open
opened 2026-08-28 22:02:15 +01:00 by maxime · 4 comments
Owner

Description

Le dépôt vit sur Forgejo (git.lozach.eu). Une intégration continue y est déclarée, elle se déclenche, et elle échoue : rien ne vérifie une branche avant son rebase sur main, à part la discipline de /branch-review lancé à la main.

Ce qui existe déjà, et qui tourne mal
.github/workflows/ci.yml décrit quatre jobs — lint, typecheck, test, puis build qui dépend des deux précédents — sur Node 22, chacun faisant make install puis sa cible. Forgejo lit ce répertoire : neuf exécutions existent sur l'instance, sur push vers main, la dernière sur a888ec4. Le run 6 a réellement exécuté lint (2 min 15) et a échoué sur le formatage d'alors ; les exécutions 7 et 8 échouent en 2 s, c'est-à-dire sans démarrer. Le fichier n'est donc pas mort, il est rouge — et un rouge que personne ne regarde s'installe.

Ce qu'il faut trancher

  • Le runner. Il existe (0604bb33-109a-447c-b536-7f7857749495, labels self-hosted, ubuntu-latest, linux_arm64) mais quelque chose l'a rendu indisponible : c'est ce que dit un job qui échoue en 2 s. C'est la seule dépendance d'infrastructure du ticket.
  • Ce qu'on lance. lint, typecheck et test sont rapides et sans état. La suite end-to-end (cd e2e && npm test) demande une instance Nextcloud complète (make up), impose son propre état de départ et vide la corbeille des agendas — et surtout, elle pilote elle-même docker compose, ce qui décide de la forme du job.
  • Le déclenchement. main n'accepte que des fast-forward, donc une CI sur main ne valide que ce qui est déjà rentré. La valeur est sur la branche, avant le rebase.
  • Le déploiement. Le Dockerfile est multi-stage et make image construit l'image. Publier une image taguée par version rejoint la publication sur l'App Store : décider si la CI s'arrête à la vérification ou va jusqu'à la livraison.

Deux affirmations de la description d'origine ont été corrigées ci-dessus après vérification : Forgejo n'ignore pas .github/workflows/, et make lint ne casse plus sur main (0 erreur, 265 avertissements, Prettier vert) — le formatage d'e2e/tests/offline.spec.ts a été corrigé entre-temps. Le relevé est dans le commentaire de conception.

Plan d'action

La conception est dans ce commentaire.

  • Lire dans l'interface web les logs des exécutions 6, 7 et 8, et nommer la cause des échecs de 2 s — npm ci meurt sur Bad system call, c'est-à-dire SIGSYS : un filtre seccomp, pas une erreur de npm
  • Établir la correspondance des labels du runner — self-hosted:host, ubuntu-latest:host, linux_arm64:docker://node:22-bookworm : un seul label est conteneurisé, et ce n'est pas celui qu'on croyait
  • Réparer le SIGSYS sur la machine du runner — l'unité est forgejo_runner.service et porte SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap, dont l'action par défaut est de tuer. Drop-in avec SystemCallErrorNumber=EPERM plutôt que de deviner l'ensemble coupable. Sans effet sur checks, qui tourne en conteneur ; bloquant pour la e2e, qui doit rester sur l'hôte
  • Ouvrir le chemin du conteneur vers la forge — actions/checkout échoue à joindre git.lozach.eu:443 depuis le bridge Docker (runs 9 et 10). container.options: --add-host=git.lozach.eu:host-gateway dans /var/www/forgejo_runner/config.yml — mesuré : bridge par défaut vers l'IP publique passe, réseau dédié vers l'IP publique est refusé, réseau dédié vers la passerelle de l'hôte passe
  • Lever ProtectSystem=full pour la e2e, ou installer une fois à la main les dépendances système de Chromium et retirer --with-deps du workflow — /usr en lecture seule fait échouer l'apt-get que ce drapeau déclenche
  • Vérifier sur l'hôte ce que la e2e demande en propre — docker compose v5.5.0 répond en tant que forgejo_runner, port 8080 libre
  • .github/workflows/ci.yml : les quatre jobs remplacés par un job checks unique — un make install, puis lint, typecheck, test, build — sur push, toute branche, runs-on: linux_arm64, Node 22
  • .github/workflows/e2e.yml : job hôte (runs-on: self-hosted), push sur main et workflow_dispatch ; make install, Chromium, make build-frontend, make up, npm test, puis make down en if: always()
  • Téléverser e2e/playwright-report/ et e2e/test-results/ à l'échec, sans quoi un échec en CI n'est qu'une ligne rouge
  • Premier tour vert pour checks — run 34, 0 erreur de lint, 35 + 21 + 21 fichiers de tests passés
  • Retirer actions/setup-node du job checks et le remplacer par un contrôle du majeur de Node — 6 min 30 sur 9 min 25 pour réinstaller la version déjà présente et attendre un cache injoignable
  • Plafonner le pool de workers de vitest — le conteneur voit tous les cœurs de l'hôte, les workers s'affament, et deux assertions synchrones ont dépassé le délai de 5 s au run 35 alors qu'elles passent en moins d'une milliseconde
  • Premier tour vert pour la e2e — run 39, 96 tests en 17,6 min, make down a tout retiré. Sur la branche et non sur main, via un déclencheur temporaire
  • Retirer le déclencheur temporaire de e2e.yml — il avait atteint main (c6c9fd6), retiré par c6748b2 plutôt que par une réécriture : main ne fait que des avances rapides
  • Confirmer que les quatre cibles tiennent en arm64 — vertes sur le runner lui-même au run 34
  • Confirmer que Chromium tient en arm64 — 96 tests au run 39, aucun plantage du moteur de rendu

Recette

  • make lint typecheck test — vertes au run 40 (0 erreur, 265 avertissements ; 426 + 344 + 344 tests)
  • Un push sur une branche déclenche checks, et lui seul — runs 44 et 46, verts, sans e2e associée
  • Un push sur main déclenche checks puis la e2e — vérifié au rebase du 06/09 : checks vert en 1 min 56, la e2e enchaîne
  • workflow_dispatch lance bien la e2e sur une branche
  • Une erreur introduite exprès rougit l'exécution, et le log dit laquelle des quatre cibles a cassé — observé au run 35 (make: *** [Makefile:67: test] Error 1)
  • Un échec e2e produit un rapport téléversé, ouvrable, avec sa trace — run 41, 92 fichiers et 57,9 Mo téléversés par forgejo/upload-artifact@v4 ; les error-context.md, captures et trace.zip sont là et lisibles
  • Après un échec, make down a bien tourné : aucun conteneur ne survit au job — vérifié aux runs 38 et 41
  • Deux exécutions e2e d'affilée passent — run 39 (96 verts) puis run 45 (95 verts, 1 échec). La seconde démarre bien sur ce que la première a laissé ; l'échec est un dépassement de délai de 60 s sur un clic dont l'élément était visible, stable et actif — de la lenteur, pas une régression
  • Décider quoi faire de l'instabilité de la machine — run 38 (Chromium s'effondre, disparu au redémarrage sans cause identifiée), run 45 (un test en dépassement de délai). La suite rougit par intermittence sans que le code soit en cause
  • Revue technique personnelle
  • Mise à jour du wiki — Workflow : nouvelle section « What CI runs, and what it does not » (les deux workflows, pourquoi checks regarde les branches et pas main, pourquoi la e2e ne tourne pas sur branche, et que la CI n'est pas une barrière d'intégration) ; Dev environment : nouvelle section sur le runner — labels, pourquoi la e2e doit être sur l'hôte, --add-host pour joindre la forge, le durcissement systemd, ce que les workflows n'utilisent pas et pourquoi, et l'instabilité non résolue de la machine
  • Revue fonctionnelle

Plan de MEP

  • Aucune livraison applicative : ce ticket ne touche ni le code de l'application, ni le schéma, ni l'image publiée. Rien à migrer, rien à annoncer.
  • Côté infrastructure, une seule dépendance : le runner doit être en ligne, et le rester. Un runner absent rend chaque exécution rouge sans qu'aucun code soit en cause.
  • Débloque #11, qui attend une CI capable de construire et de publier l'image plutôt qu'une commande lancée depuis un poste.
## Description Le dépôt vit sur Forgejo (`git.lozach.eu`). Une intégration continue y est déclarée, elle se déclenche, et elle échoue : rien ne vérifie une branche avant son rebase sur `main`, à part la discipline de `/branch-review` lancé à la main. **Ce qui existe déjà, et qui tourne mal** `.github/workflows/ci.yml` décrit quatre jobs — `lint`, `typecheck`, `test`, puis `build` qui dépend des deux précédents — sur Node 22, chacun faisant `make install` puis sa cible. **Forgejo lit ce répertoire** : neuf exécutions existent sur l'instance, sur `push` vers `main`, la dernière sur `a888ec4`. Le run 6 a réellement exécuté `lint` (2 min 15) et a échoué sur le formatage d'alors ; les exécutions 7 et 8 échouent en 2 s, c'est-à-dire sans démarrer. Le fichier n'est donc pas mort, il est rouge — et un rouge que personne ne regarde s'installe. **Ce qu'il faut trancher** - **Le runner.** Il existe (`0604bb33-109a-447c-b536-7f7857749495`, labels `self-hosted`, `ubuntu-latest`, `linux_arm64`) mais quelque chose l'a rendu indisponible : c'est ce que dit un job qui échoue en 2 s. C'est la seule dépendance d'infrastructure du ticket. - **Ce qu'on lance.** `lint`, `typecheck` et `test` sont rapides et sans état. La suite end-to-end (`cd e2e && npm test`) demande une instance Nextcloud complète (`make up`), impose son propre état de départ et vide la corbeille des agendas — et surtout, elle pilote elle-même `docker compose`, ce qui décide de la forme du job. - **Le déclenchement.** `main` n'accepte que des fast-forward, donc une CI sur `main` ne valide que ce qui est déjà rentré. La valeur est sur la branche, avant le rebase. - **Le déploiement.** Le `Dockerfile` est multi-stage et `make image` construit l'image. Publier une image taguée par version rejoint la publication sur l'App Store : décider si la CI s'arrête à la vérification ou va jusqu'à la livraison. *Deux affirmations de la description d'origine ont été corrigées ci-dessus après vérification : Forgejo n'ignore pas `.github/workflows/`, et `make lint` ne casse plus sur `main` (0 erreur, 265 avertissements, Prettier vert) — le formatage d'`e2e/tests/offline.spec.ts` a été corrigé entre-temps. Le relevé est [dans le commentaire de conception](https://git.lozach.eu/maxime/OrganisateurFamilial/issues/6#issuecomment-103).* ## Plan d'action La conception est [dans ce commentaire](https://git.lozach.eu/maxime/OrganisateurFamilial/issues/6#issuecomment-103). - [x] Lire dans l'interface web les logs des exécutions 6, 7 et 8, et nommer la cause des échecs de 2 s — `npm ci` meurt sur `Bad system call`, c'est-à-dire SIGSYS : un filtre seccomp, pas une erreur de npm - [x] Établir la correspondance des labels du runner — `self-hosted:host`, `ubuntu-latest:host`, `linux_arm64:docker://node:22-bookworm` : un seul label est conteneurisé, et ce n'est pas celui qu'on croyait - [x] Réparer le SIGSYS sur la machine du runner — l'unité est `forgejo_runner.service` et porte `SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap`, dont l'action par défaut est de tuer. Drop-in avec `SystemCallErrorNumber=EPERM` plutôt que de deviner l'ensemble coupable. Sans effet sur `checks`, qui tourne en conteneur ; **bloquant pour la e2e**, qui doit rester sur l'hôte - [x] Ouvrir le chemin du conteneur vers la forge — `actions/checkout` échoue à joindre `git.lozach.eu:443` depuis le bridge Docker (runs 9 et 10). `container.options: --add-host=git.lozach.eu:host-gateway` dans `/var/www/forgejo_runner/config.yml` — mesuré : bridge par défaut vers l'IP publique passe, réseau dédié vers l'IP publique est refusé, réseau dédié vers la passerelle de l'hôte passe - [x] Lever `ProtectSystem=full` pour la e2e, ou installer une fois à la main les dépendances système de Chromium et retirer `--with-deps` du workflow — `/usr` en lecture seule fait échouer l'`apt-get` que ce drapeau déclenche - [x] Vérifier sur l'hôte ce que la e2e demande en propre — `docker compose` v5.5.0 répond en tant que `forgejo_runner`, port 8080 libre - [x] `.github/workflows/ci.yml` : les quatre jobs remplacés par un job `checks` unique — un `make install`, puis `lint`, `typecheck`, `test`, `build` — sur `push`, toute branche, `runs-on: linux_arm64`, Node 22 - [x] `.github/workflows/e2e.yml` : job hôte (`runs-on: self-hosted`), `push` sur `main` et `workflow_dispatch` ; `make install`, Chromium, `make build-frontend`, `make up`, `npm test`, puis `make down` en `if: always()` - [x] Téléverser `e2e/playwright-report/` et `e2e/test-results/` à l'échec, sans quoi un échec en CI n'est qu'une ligne rouge - [x] Premier tour vert pour `checks` — run 34, 0 erreur de lint, 35 + 21 + 21 fichiers de tests passés - [x] Retirer `actions/setup-node` du job `checks` et le remplacer par un contrôle du majeur de Node — 6 min 30 sur 9 min 25 pour réinstaller la version déjà présente et attendre un cache injoignable - [x] Plafonner le pool de workers de vitest — le conteneur voit tous les cœurs de l'hôte, les workers s'affament, et deux assertions synchrones ont dépassé le délai de 5 s au run 35 alors qu'elles passent en moins d'une milliseconde - [x] Premier tour vert pour la e2e — run 39, 96 tests en 17,6 min, `make down` a tout retiré. Sur la branche et non sur `main`, via un déclencheur temporaire - [x] Retirer le déclencheur temporaire de `e2e.yml` — il avait atteint `main` (`c6c9fd6`), retiré par `c6748b2` plutôt que par une réécriture : `main` ne fait que des avances rapides - [x] Confirmer que les quatre cibles tiennent en arm64 — vertes sur le runner lui-même au run 34 - [x] Confirmer que Chromium tient en arm64 — 96 tests au run 39, aucun plantage du moteur de rendu ## Recette - [x] `make lint typecheck test` — vertes au run 40 (0 erreur, 265 avertissements ; 426 + 344 + 344 tests) - [x] Un push sur une branche déclenche `checks`, et lui seul — runs 44 et 46, verts, sans e2e associée - [x] Un push sur `main` déclenche `checks` puis la e2e — vérifié au rebase du 06/09 : `checks` vert en 1 min 56, la e2e enchaîne - [x] `workflow_dispatch` lance bien la e2e sur une branche - [x] Une erreur introduite exprès rougit l'exécution, et le log dit laquelle des quatre cibles a cassé — observé au run 35 (`make: *** [Makefile:67: test] Error 1`) - [x] Un échec e2e produit un rapport téléversé, ouvrable, avec sa trace — run 41, 92 fichiers et 57,9 Mo téléversés par `forgejo/upload-artifact@v4` ; les `error-context.md`, captures et `trace.zip` sont là et lisibles - [x] Après un échec, `make down` a bien tourné : aucun conteneur ne survit au job — vérifié aux runs 38 et 41 - [x] Deux exécutions e2e d'affilée passent — run 39 (96 verts) puis run 45 (95 verts, 1 échec). La seconde démarre bien sur ce que la première a laissé ; l'échec est un dépassement de délai de 60 s sur un clic dont l'élément était visible, stable et actif — de la lenteur, pas une régression - [x] Décider quoi faire de l'instabilité de la machine — run 38 (Chromium s'effondre, disparu au redémarrage sans cause identifiée), run 45 (un test en dépassement de délai). La suite rougit par intermittence sans que le code soit en cause - [x] Revue technique personnelle - [x] Mise à jour du wiki — `Workflow` : nouvelle section « What CI runs, and what it does not » (les deux workflows, pourquoi `checks` regarde les branches et pas `main`, pourquoi la e2e ne tourne pas sur branche, et que la CI n'est pas une barrière d'intégration) ; `Dev environment` : nouvelle section sur le runner — labels, pourquoi la e2e doit être sur l'hôte, `--add-host` pour joindre la forge, le durcissement systemd, ce que les workflows n'utilisent pas et pourquoi, et l'instabilité non résolue de la machine - [x] Revue fonctionnelle ## Plan de MEP - Aucune livraison applicative : ce ticket ne touche ni le code de l'application, ni le schéma, ni l'image publiée. Rien à migrer, rien à annoncer. - Côté infrastructure, une seule dépendance : le runner doit être en ligne, et le rester. Un runner absent rend chaque exécution rouge sans qu'aucun code soit en cause. - Débloque #11, qui attend une CI capable de construire et de publier l'image plutôt qu'une commande lancée depuis un poste.
maxime added this to the 1.0.x milestone 2026-08-28 22:02:15 +01:00
claudeagent changed title from Implémenter les actions to Mettre en place l'intégration continue (Forgejo Actions) 2026-08-30 14:07:35 +01:00
Collaborator

Conception — la CI sur le runner Forgejo

1. L'état réel, relevé avant d'écrire quoi que ce soit

Deux affirmations de la description étaient fausses, et la conception change selon la
réponse. Les deux ont été vérifiées, pas supposées.

La CI est déjà branchée. Forgejo lit aussi .github/workflows/ : neuf exécutions de
ci.yml existent sur l'instance, déclenchées par push sur main, la dernière sur
a888ec4. Le fichier n'est donc pas mort — il est actif et rouge, ce qui est pire, parce
qu'un rouge que personne ne regarde s'installe.

run job durée verdict
6 lint 2 min 15 échec
6 typecheck, test 2 s chacun échec
7 et 8 les trois 2 s chacun échec

Le run 6 a vraiment exécuté lint — 2 min 15, c'est setup-node plus make install plus
eslint — et a échoué sur le formatage d'alors. Les 2 s qui suivent ne sont pas une
exécution : c'est un job qui n'a jamais démarré. Les logs ne sont lisibles que dans
l'interface web
: /api/v1/repos/…/actions/runs/{id} répond 404 sur cette version, et le
jeton du robot n'est pas propriétaire du dépôt. L'hypothèse — le runner devenu
indisponible entre 14:56 et 20:35 le 30/08 — est vraisemblable et non établie, d'où la
première ligne du plan d'action.

make lint passe sur main : 0 erreur, 265 avertissements, et Prettier ne signale
rien. Le formatage d'offline.spec.ts a été corrigé entre-temps. Il n'y a donc pas de
correctif à grouper avec l'activation.

Ce qui reste vrai de la description : rien ne se déclenche sur une branche, la suite
end-to-end ne tourne nulle part, et les trois dernières exécutions échouent.

2. Le fichier reste .github/workflows/

Le répertoire désigne une forge qu'on n'utilise pas, mais Forgejo le lit — c'est prouvé
ci-dessus, pas déduit d'une documentation — et un miroir GitHub, s'il arrive un jour, le
reprend tel quel. Déplacer vers .forgejo/workflows/ n'achèterait qu'un nom honnête, au
prix d'un aller-retour le jour du miroir. Le coût est le nom ; il est noté ici pour qu'il
ne se redécouvre pas.

3. Un seul job checks, à chaque push, sur toute branche

Ce que coûtent les cibles, mesuré sur le poste :

cible durée
make lint 7,7 s
make typecheck 4,3 s
make test 6,8 s
make build 6,0 s

Vingt-cinq secondes de vérifications. Tout le reste du temps d'une exécution est
make install, c'est-à-dire trois npm ci — racine, ex_app/lib, ex_app/src. Le
découpage actuel en quatre jobs les paie quatre fois, et n'achète aucun parallélisme
en échange : le runner est à capacité 1, ce que le run 6 montre noir sur blanc — lint
finit à 14:56:05, typecheck démarre à 14:56:08, test à 14:56:12.

Donc un job unique, checks, qui installe une fois puis enchaîne lint, typecheck,
test, build dans cet ordre. Ce que ça coûte : une seule coche, et un lint rouge
arrête avant les tests — il faut ouvrir le log pour savoir laquelle des quatre a cassé.
L'ordre est choisi pour ça : la cible la plus rapide et la plus probable d'abord.

Déclenchement : chaque push, sur toute branche. main n'accepte que des
fast-forward et il n'y a pas de pull request : une CI qui ne regarde que main valide ce
qui est déjà rentré. Le seul moment où un échec évite quelque chose est la branche
feat/…, avant le rebase. main reste couvert, un push y étant un push.

Trois détails qui ne sont pas des détails :

  • runs-on: ubuntu-latest — le label existe sur le runner (0604bb33-109a-447c-b536-7f7857749495,
    labels self-hosted, ubuntu-latest, linux_arm64) et y a déjà exécuté un job.
  • Node reste 22. L'image de production est node:22-slim ; le poste est en 24. La CI
    vérifie ce qui est livré, pas ce qui est confortable.
  • Le lint passe tant qu'il n'y a pas d'erreur, c'est-à-dire npm run lint inchangé. La
    CI dit exactement ce que dit la commande locale — sans ça, elle devient un second
    référentiel que personne ne peut reproduire chez soi. Les 265 avertissements ne sont pas
    un chantier de ce ticket.

4. La e2e : pourquoi elle ne peut pas être un job comme les autres

Le point dur, et il n'est pas où on l'attend. Ce n'est pas que la suite ait besoin d'une
instance Nextcloud — c'est qui la pilote.

e2e/tests/instance.ts appelle execFileSync('docker', ['compose', 'exec', …]) depuis le
processus Playwright lui-même, avec la racine du dépôt pour cwd : psql() et occ()
passent par là, et bringToZero s'en sert avant chaque campagne et après. Deux
conséquences fermes :

  • Nextcloud ne peut pas être un services: du workflow. Les tests ne le trouveraient
    pas : ils ne connaissent que docker compose, dans ce dépôt, avec ce nom de projet.
  • Le job ne peut pas tourner dans un conteneur ordinaire. Il lui faudrait le CLI
    docker, le socket de l'hôte, et le checkout au même chemin dedans et dehors — les
    bind mounts de compose.yaml (./ex_app/lib/src, ./ex_app/src/dist/js, ./ex_app/img)
    se résolvent côté hôte. Trois conditions dont la troisième se casse en silence : un
    répertoire vide monté à la place des sources ne fait pas d'erreur, il fait un bundle
    absent.

Le job e2e s'exécute donc sur l'hôte — runs-on: self-hosted, le label sans image —
là où make up, docker compose exec et localhost:8080 valent ce qu'ils valent en
local. À vérifier avant d'écrire le fichier : que self-hosted est bien configuré en
exécution hôte sur ce runner, et non associé à une image comme ubuntu-latest.

La séquence, dans cet ordre et pas un autre :

  1. make install
  2. npx playwright install --with-deps chromium, le cache du runner évitant le
    téléchargement aux tours suivants
  3. make build-frontend — avant make up. Compose monte ex_app/src/dist/js ; un
    répertoire vide donne une suite qui teste l'absence de bundle et peut très bien passer.
    C'est le premier des deux pièges que la page Workflow du wiki liste déjà.
  4. make up — postgres, nextcloud:34-apache, l'ExApp, puis dev/setup.sh
  5. cd e2e && npm test
  6. make down, en if: always() — sinon la pile survit au job et le suivant démarre sur
    l'état du précédent, ce que la suite passe justement son temps à interdire

dev/setup.sh est réutilisé tel quel, sans variante CI : c'est lui qui pose l'état que
la suite attend (D29) — quotas DAV desserrés, allow_local_remote_servers, compte alice,
daemon manual_install, enregistrement de l'ExApp. Une copie adaptée aurait divergé dans
la semaine, et la divergence se serait vue comme un échec de test.

Cadence : push sur main, plus workflow_dispatch. 93 tests, un seul worker, en
série (workers: 1, fullyParallel: false — une instance partagée, D29), derrière
l'installation complète d'un Nextcloud, sur un runner à capacité 1 : sur chaque branche,
deux pushs rapprochés feraient la queue derrière plusieurs minutes chacun.
workflow_dispatch rend la suite lançable à la main sur une branche avant le rebase, qui
est le moment où on en a besoin.

Elle est bloquante. Une suite dont l'échec n'a pas de conséquence cesse d'être lue en
quelques semaines, et on aura payé le runner pour rien.

À l'échec, les artefacts remontent : e2e/playwright-report/ et e2e/test-results/.
La configuration pose déjà trace: 'retain-on-failure' et screenshot: 'only-on-failure' ;
sans téléversement, tout ça reste sur le runner et un échec en CI n'est qu'une ligne rouge.

Deux risques nommés, à vérifier et non à supposer :

  • arm64. Le runner est linux_arm64. nextcloud:34-apache et postgres:16-alpine sont
    multi-arch, le Chromium de Playwright existe pour Ubuntu arm64 — c'est néanmoins la
    première chose qui cassera, et ça se voit au premier tour.
  • L'hôte n'est pas jetable. Le port 8080 doit être libre, et la suite supprime toutes
    les familles qu'elle trouve avant de démarrer. Elle ne doit jamais viser autre chose que
    la pile que le job vient de monter.

5. Ce que ce ticket ne fait pas

  • Pas de publication d'image. make image construit et pousse sur ghcr.io ; ça demande
    un secret de registre dans Forgejo et une politique de tags par version. Ça appartient à
    #11, qui attend précisément cette CI pour ne pas publier depuis un poste.
  • Pas de nettoyage des 265 avertissements, ni de resserrement du lint.
  • La CI ne devient pas une barrière d'intégration. main reste en fast-forward seul,
    il n'y a pas de pull request à bloquer : elle dit ce qui va, sur la branche, avant le
    rebase. C'est une information, et la discipline reste la vôtre.
## Conception — la CI sur le runner Forgejo ### 1. L'état réel, relevé avant d'écrire quoi que ce soit Deux affirmations de la description étaient fausses, et la conception change selon la réponse. Les deux ont été vérifiées, pas supposées. **La CI est déjà branchée.** Forgejo lit aussi `.github/workflows/` : neuf exécutions de `ci.yml` existent sur l'instance, déclenchées par `push` sur `main`, la dernière sur `a888ec4`. Le fichier n'est donc pas mort — il est actif et rouge, ce qui est pire, parce qu'un rouge que personne ne regarde s'installe. | run | job | durée | verdict | |---|---|---|---| | 6 | `lint` | 2 min 15 | échec | | 6 | `typecheck`, `test` | 2 s chacun | échec | | 7 et 8 | les trois | 2 s chacun | échec | Le run 6 a vraiment exécuté `lint` — 2 min 15, c'est `setup-node` plus `make install` plus `eslint` — et a échoué sur le formatage d'alors. Les 2 s qui suivent ne sont pas une exécution : c'est un job qui n'a jamais démarré. **Les logs ne sont lisibles que dans l'interface web** : `/api/v1/repos/…/actions/runs/{id}` répond 404 sur cette version, et le jeton du robot n'est pas propriétaire du dépôt. L'hypothèse — le runner devenu indisponible entre 14:56 et 20:35 le 30/08 — est vraisemblable et non établie, d'où la première ligne du plan d'action. **`make lint` passe sur `main`** : 0 erreur, 265 avertissements, et Prettier ne signale rien. Le formatage d'`offline.spec.ts` a été corrigé entre-temps. Il n'y a donc pas de correctif à grouper avec l'activation. Ce qui reste vrai de la description : rien ne se déclenche sur une branche, la suite end-to-end ne tourne nulle part, et les trois dernières exécutions échouent. ### 2. Le fichier reste `.github/workflows/` Le répertoire désigne une forge qu'on n'utilise pas, mais Forgejo le lit — c'est prouvé ci-dessus, pas déduit d'une documentation — et un miroir GitHub, s'il arrive un jour, le reprend tel quel. Déplacer vers `.forgejo/workflows/` n'achèterait qu'un nom honnête, au prix d'un aller-retour le jour du miroir. Le coût est le nom ; il est noté ici pour qu'il ne se redécouvre pas. ### 3. Un seul job `checks`, à chaque push, sur toute branche **Ce que coûtent les cibles, mesuré sur le poste :** | cible | durée | |---|---| | `make lint` | 7,7 s | | `make typecheck` | 4,3 s | | `make test` | 6,8 s | | `make build` | 6,0 s | Vingt-cinq secondes de vérifications. Tout le reste du temps d'une exécution est `make install`, c'est-à-dire trois `npm ci` — racine, `ex_app/lib`, `ex_app/src`. Le découpage actuel en quatre jobs les paie **quatre fois**, et n'achète aucun parallélisme en échange : le runner est à capacité 1, ce que le run 6 montre noir sur blanc — `lint` finit à 14:56:05, `typecheck` démarre à 14:56:08, `test` à 14:56:12. Donc un job unique, `checks`, qui installe une fois puis enchaîne `lint`, `typecheck`, `test`, `build` dans cet ordre. Ce que ça coûte : une seule coche, et un `lint` rouge arrête avant les tests — il faut ouvrir le log pour savoir laquelle des quatre a cassé. L'ordre est choisi pour ça : la cible la plus rapide et la plus probable d'abord. **Déclenchement : chaque push, sur toute branche.** `main` n'accepte que des fast-forward et il n'y a pas de pull request : une CI qui ne regarde que `main` valide ce qui est déjà rentré. Le seul moment où un échec évite quelque chose est la branche `feat/…`, avant le rebase. `main` reste couvert, un push y étant un push. Trois détails qui ne sont pas des détails : - `runs-on: ubuntu-latest` — le label existe sur le runner (`0604bb33-109a-447c-b536-7f7857749495`, labels `self-hosted`, `ubuntu-latest`, `linux_arm64`) et y a déjà exécuté un job. - **Node reste 22.** L'image de production est `node:22-slim` ; le poste est en 24. La CI vérifie ce qui est livré, pas ce qui est confortable. - **Le lint passe tant qu'il n'y a pas d'erreur**, c'est-à-dire `npm run lint` inchangé. La CI dit exactement ce que dit la commande locale — sans ça, elle devient un second référentiel que personne ne peut reproduire chez soi. Les 265 avertissements ne sont pas un chantier de ce ticket. ### 4. La e2e : pourquoi elle ne peut pas être un job comme les autres Le point dur, et il n'est pas où on l'attend. Ce n'est pas que la suite ait besoin d'une instance Nextcloud — c'est **qui la pilote**. `e2e/tests/instance.ts` appelle `execFileSync('docker', ['compose', 'exec', …])` depuis le processus Playwright lui-même, avec la racine du dépôt pour `cwd` : `psql()` et `occ()` passent par là, et `bringToZero` s'en sert avant chaque campagne et après. Deux conséquences fermes : - **Nextcloud ne peut pas être un `services:` du workflow.** Les tests ne le trouveraient pas : ils ne connaissent que `docker compose`, dans ce dépôt, avec ce nom de projet. - **Le job ne peut pas tourner dans un conteneur ordinaire.** Il lui faudrait le CLI docker, le socket de l'hôte, *et* le checkout au même chemin dedans et dehors — les bind mounts de `compose.yaml` (`./ex_app/lib/src`, `./ex_app/src/dist/js`, `./ex_app/img`) se résolvent côté hôte. Trois conditions dont la troisième se casse en silence : un répertoire vide monté à la place des sources ne fait pas d'erreur, il fait un bundle absent. Le job e2e s'exécute donc **sur l'hôte** — `runs-on: self-hosted`, le label sans image — là où `make up`, `docker compose exec` et `localhost:8080` valent ce qu'ils valent en local. **À vérifier avant d'écrire le fichier** : que `self-hosted` est bien configuré en exécution hôte sur ce runner, et non associé à une image comme `ubuntu-latest`. **La séquence, dans cet ordre et pas un autre :** 1. `make install` 2. `npx playwright install --with-deps chromium`, le cache du runner évitant le téléchargement aux tours suivants 3. `make build-frontend` — **avant** `make up`. Compose monte `ex_app/src/dist/js` ; un répertoire vide donne une suite qui teste l'absence de bundle et peut très bien passer. C'est le premier des deux pièges que la page `Workflow` du wiki liste déjà. 4. `make up` — postgres, `nextcloud:34-apache`, l'ExApp, puis `dev/setup.sh` 5. `cd e2e && npm test` 6. `make down`, en `if: always()` — sinon la pile survit au job et le suivant démarre sur l'état du précédent, ce que la suite passe justement son temps à interdire `dev/setup.sh` est réutilisé **tel quel**, sans variante CI : c'est lui qui pose l'état que la suite attend (D29) — quotas DAV desserrés, `allow_local_remote_servers`, compte `alice`, daemon `manual_install`, enregistrement de l'ExApp. Une copie adaptée aurait divergé dans la semaine, et la divergence se serait vue comme un échec de test. **Cadence : `push` sur `main`, plus `workflow_dispatch`.** 93 tests, un seul worker, en série (`workers: 1`, `fullyParallel: false` — une instance partagée, D29), derrière l'installation complète d'un Nextcloud, sur un runner à capacité 1 : sur chaque branche, deux pushs rapprochés feraient la queue derrière plusieurs minutes chacun. `workflow_dispatch` rend la suite lançable à la main sur une branche avant le rebase, qui est le moment où on en a besoin. **Elle est bloquante.** Une suite dont l'échec n'a pas de conséquence cesse d'être lue en quelques semaines, et on aura payé le runner pour rien. **À l'échec, les artefacts remontent** : `e2e/playwright-report/` et `e2e/test-results/`. La configuration pose déjà `trace: 'retain-on-failure'` et `screenshot: 'only-on-failure'` ; sans téléversement, tout ça reste sur le runner et un échec en CI n'est qu'une ligne rouge. **Deux risques nommés, à vérifier et non à supposer :** - **arm64.** Le runner est `linux_arm64`. `nextcloud:34-apache` et `postgres:16-alpine` sont multi-arch, le Chromium de Playwright existe pour Ubuntu arm64 — c'est néanmoins la première chose qui cassera, et ça se voit au premier tour. - **L'hôte n'est pas jetable.** Le port 8080 doit être libre, et la suite supprime *toutes* les familles qu'elle trouve avant de démarrer. Elle ne doit jamais viser autre chose que la pile que le job vient de monter. ### 5. Ce que ce ticket ne fait pas - **Pas de publication d'image.** `make image` construit et pousse sur ghcr.io ; ça demande un secret de registre dans Forgejo et une politique de tags par version. Ça appartient à #11, qui attend précisément cette CI pour ne pas publier depuis un poste. - **Pas de nettoyage des 265 avertissements**, ni de resserrement du lint. - **La CI ne devient pas une barrière d'intégration.** `main` reste en fast-forward seul, il n'y a pas de pull request à bloquer : elle dit ce qui va, sur la branche, avant le rebase. C'est une information, et la discipline reste la vôtre.
Collaborator

Le runner, et ce que ses logs corrigent dans la conception

1. Bad system call n'est pas une erreur de npm

make install
npm ci
make: *** [Makefile:42: install] Bad system call

Bad system call est SIGSYS : un filtre seccomp qui tue le processus au lieu de lui
refuser un appel système. npm ci meurt donc instantanément, et make ne fait que
rapporter la mort de son fils. Aucun npm ci n'a jamais eu la moindre chance sur ces trois
exécutions — ce qui explique les 2 s.

Sur un job qui s'exécute sur l'hôte, le filtre ne peut venir que de l'environnement du
processus runner. Le suspect par défaut est le durcissement systemd de son unité :
SystemCallFilter= sans SystemCallErrorNumber= a précisément SIGSYS pour action. Ça
recolle aussi à la chronologie — 2 min 15 qui tournent à 14:53, puis plus rien à partir de
14:56 : un runner lancé à la main dans un terminal, puis installé en service.

Les deux commandes qui tranchent, sur la machine du runner :

sudo dmesg | grep -i seccomp | tail -20
systemctl cat forgejo-runner.service | grep -iE 'SystemCall|MemoryDeny|NoNewPrivileges|ProtectSystem|RestrictNamespaces'

La première nomme l'appel bloqué (audit: type=1326 … syscall=NNN), la seconde dit qui le
bloque. Correctif au choix : vider SystemCallFilter=, ou poser
SystemCallErrorNumber=EPERM pour que l'appel soit refusé plutôt que fatal.

2. Les labels ne veulent pas dire ce que leur nom suggère

labels:
  - self-hosted:host
  - ubuntu-latest:host
  - linux_arm64:docker://node:22-bookworm

ubuntu-latest est de l'exécution hôte. La conception le supposait conteneurisé, et le
ci.yml écrit hier faisait donc tourner checks sur la machine sans que ce soit un choix.
Le seul label conteneurisé est linux_arm64, et il désigne node:22-bookworm.

Ce que ça change, dans les deux sens :

  • checks passe en runs-on: linux_arm64. Le job s'isole de l'état de la machine, et
    le seccomp de l'hôte ne l'atteint pas : il peut redevenir vert sans attendre le correctif
    système. Le coût est le nom du label, qui annonce une architecture et pas une intention —
    d'où le commentaire dans le fichier, pour que ça ne se redécouvre pas.
  • La e2e reste sur self-hosted, et reste bloquée. Elle doit être sur l'hôte, c'est
    tout son intérêt (elle pilote docker compose elle-même) ; elle ne peut donc pas
    contourner le filtre en changeant de label. Réparer le SIGSYS n'est pas optionnel, c'est
    seulement devenu non bloquant pour la moitié rapide.

actions/setup-node est conservé dans le conteneur alors que l'image porte déjà Node 22 :
il n'y reste que pour le cache npm des trois package-lock.json, qui est la part dominante
du temps d'une exécution.

3. Répétition complète du job checks, hors runner

Le contenu du job a été rejoué dans l'image du label, en arm64, sur un export propre de
HEAD — donc sans node_modules hérités — avec un cache npm froid :

étape durée résultat
make install 18 s trois npm ci
make lint 14 s 0 erreur, 265 avertissements
make typecheck 8 s
make test 28 s 426 backend, 344 frontend
make build 11 s
total 79 s

node:22-bookworm porte make, git, python3, g++ et curl : la chaîne native de
better-sqlite3 est là, rien à ajouter à l'image.

Un avertissement relevé au passage et laissé tel quel : rollup-plugin-node-externals@9.0.1
déclare node >= 24 alors que la CI et l'image de production sont en 22. C'est un
EBADENGINE, npm ne l'applique pas, et la construction passe — mais c'est le genre de
chose qui casse sans prévenir à la montée de version, et la répétition ci-dessus est la
seule raison pour laquelle on le sait.

4. Ce qui reste

checks est prêt et a été vérifié aussi loin qu'on peut le faire sans le runner ; il ne
manque que le premier tour. La e2e est écrite mais ne peut pas être verte avant le
correctif seccomp — et Chromium en arm64 n'a toujours pas été essayé.

## Le runner, et ce que ses logs corrigent dans la conception ### 1. `Bad system call` n'est pas une erreur de npm ``` make install npm ci make: *** [Makefile:42: install] Bad system call ``` `Bad system call` est **SIGSYS** : un filtre seccomp qui tue le processus au lieu de lui refuser un appel système. `npm ci` meurt donc instantanément, et `make` ne fait que rapporter la mort de son fils. Aucun `npm ci` n'a jamais eu la moindre chance sur ces trois exécutions — ce qui explique les 2 s. Sur un job qui s'exécute **sur l'hôte**, le filtre ne peut venir que de l'environnement du processus runner. Le suspect par défaut est le durcissement systemd de son unité : `SystemCallFilter=` sans `SystemCallErrorNumber=` a précisément SIGSYS pour action. Ça recolle aussi à la chronologie — 2 min 15 qui tournent à 14:53, puis plus rien à partir de 14:56 : un runner lancé à la main dans un terminal, puis installé en service. Les deux commandes qui tranchent, sur la machine du runner : ```bash sudo dmesg | grep -i seccomp | tail -20 ``` ```bash systemctl cat forgejo-runner.service | grep -iE 'SystemCall|MemoryDeny|NoNewPrivileges|ProtectSystem|RestrictNamespaces' ``` La première nomme l'appel bloqué (`audit: type=1326 … syscall=NNN`), la seconde dit qui le bloque. Correctif au choix : vider `SystemCallFilter=`, ou poser `SystemCallErrorNumber=EPERM` pour que l'appel soit refusé plutôt que fatal. ### 2. Les labels ne veulent pas dire ce que leur nom suggère ```yaml labels: - self-hosted:host - ubuntu-latest:host - linux_arm64:docker://node:22-bookworm ``` **`ubuntu-latest` est de l'exécution hôte.** La conception le supposait conteneurisé, et le `ci.yml` écrit hier faisait donc tourner `checks` sur la machine sans que ce soit un choix. Le seul label conteneurisé est `linux_arm64`, et il désigne `node:22-bookworm`. Ce que ça change, dans les deux sens : - **`checks` passe en `runs-on: linux_arm64`.** Le job s'isole de l'état de la machine, et le seccomp de l'hôte ne l'atteint pas : il peut redevenir vert sans attendre le correctif système. Le coût est le nom du label, qui annonce une architecture et pas une intention — d'où le commentaire dans le fichier, pour que ça ne se redécouvre pas. - **La e2e reste sur `self-hosted`, et reste bloquée.** Elle *doit* être sur l'hôte, c'est tout son intérêt (elle pilote `docker compose` elle-même) ; elle ne peut donc pas contourner le filtre en changeant de label. Réparer le SIGSYS n'est pas optionnel, c'est seulement devenu non bloquant pour la moitié rapide. `actions/setup-node` est conservé dans le conteneur alors que l'image porte déjà Node 22 : il n'y reste que pour le cache npm des trois `package-lock.json`, qui est la part dominante du temps d'une exécution. ### 3. Répétition complète du job `checks`, hors runner Le contenu du job a été rejoué dans l'image du label, en arm64, sur un export propre de `HEAD` — donc sans `node_modules` hérités — avec un cache npm froid : | étape | durée | résultat | |---|---|---| | `make install` | 18 s | trois `npm ci` | | `make lint` | 14 s | 0 erreur, 265 avertissements | | `make typecheck` | 8 s | | | `make test` | 28 s | 426 backend, 344 frontend | | `make build` | 11 s | | | **total** | **79 s** | | `node:22-bookworm` porte `make`, `git`, `python3`, `g++` et `curl` : la chaîne native de `better-sqlite3` est là, rien à ajouter à l'image. Un avertissement relevé au passage et laissé tel quel : `rollup-plugin-node-externals@9.0.1` déclare `node >= 24` alors que la CI et l'image de production sont en 22. C'est un `EBADENGINE`, npm ne l'applique pas, et la construction passe — mais c'est le genre de chose qui casse sans prévenir à la montée de version, et la répétition ci-dessus est la seule raison pour laquelle on le sait. ### 4. Ce qui reste `checks` est prêt et a été vérifié aussi loin qu'on peut le faire sans le runner ; il ne manque que le premier tour. La e2e est écrite mais ne peut pas être verte avant le correctif seccomp — et Chromium en arm64 n'a toujours pas été essayé.
Collaborator

Premier tour : deux problèmes d'infrastructure, aucun dans le dépôt

Runs 9 et 10, sur feat/ci-forgejo-actions (a1d6f17). Les deux rouges, et ni l'un ni
l'autre n'a atteint une seule cible du Makefile.

1. Ce que le conteneur a réglé

Le job a tourné 5 min 05, là où les trois exécutions précédentes mouraient en 2 s. Le
SIGSYS est donc bien propre à l'exécution « host » : le conteneur linux_arm64 n'est
pas soumis au filtre du service. Le choix de label tient.

2. Le SIGSYS, cause réelle

L'unité s'appelle forgejo_runner.service — avec un souligné, ce qui explique pourquoi la
première recherche n'avait rien trouvé. Elle porte :

User=forgejo_runner
SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap
ProtectSystem=full

Une liste de refus, sans SystemCallErrorNumber= : l'action par défaut est de tuer le
processus, d'où Bad system call. Quel ensemble est coupable reste inconnu. L'hypothèse
« npm ci en root appelle setuid » ne tient pas : User=forgejo_runner dit que le runner
n'est pas privilégié, et un npm non privilégié n'appelle pas setuid. Plutôt que de deviner
à nouveau, le correctif retenu est SystemCallErrorNumber=EPERM, qui fait refuser les
appels filtrés au lieu de tuer — suffisant pour presque tout outil, et le durcissement reste.

Ça ne concerne que la e2e : elle doit être sur l'hôte, c'est son intérêt même.

3. Le conteneur ne joint pas la forge

C'est ce qui a rougi les runs 9 et 10, et c'est nouveau — apparu avec le conteneur :

fatal: unable to access 'https://git.lozach.eu/maxime/OrganisateurFamilial/':
Failed to connect to git.lozach.eu port 443 after 101 ms: Couldn't connect to server

Trois tentatives, trois refus en ~100 ms. Le nom se résout, le TCP est refusé net — ce n'est
pas un problème de DNS ni de lenteur. Un job sur le bridge Docker n'atteint pas la forge :
pare-feu fermé au sous-réseau du bridge, ou proxy qui n'écoute que sur l'IP publique et
hairpin NAT qui ne repasse pas. Les jobs « host » se clonaient très bien, ce qui cadre.

Correctif visé : container.options: --add-host=git.lozach.eu:host-gateway dans
/var/www/forgejo_runner/config.yml.

Décidé : on répare le réseau et checks reste en conteneur, plutôt que de le ramener
sur l'hôte. L'isolation vis-à-vis de ce que la machine porte était la raison du choix, et le
correctif réseau sert tout job conteneurisé à venir.

4. Un troisième point, pas encore rencontré

ProtectSystem=full met /usr en lecture seule. Le job e2e appelle
npx playwright install --with-deps chromium, dont le --with-deps est un apt-get install : il échouera. Soit on lève la protection, soit on installe les dépendances système
de Chromium une fois à la main et on retire le drapeau du workflow — la seconde garde le
durcissement et remplace une installation à chaque exécution par une opération unique.

5. État

Rien à corriger dans le dépôt : les deux workflows sont écrits, et le contenu de checks a
été répété vert de bout en bout dans l'image du label (79 s, arm64). Les trois lignes
restantes du plan d'action sont toutes sur la machine du runner.

## Premier tour : deux problèmes d'infrastructure, aucun dans le dépôt Runs 9 et 10, sur `feat/ci-forgejo-actions` (`a1d6f17`). Les deux rouges, et ni l'un ni l'autre n'a atteint une seule cible du `Makefile`. ### 1. Ce que le conteneur a réglé Le job a tourné **5 min 05**, là où les trois exécutions précédentes mouraient en 2 s. Le SIGSYS est donc bien **propre à l'exécution « host »** : le conteneur `linux_arm64` n'est pas soumis au filtre du service. Le choix de label tient. ### 2. Le SIGSYS, cause réelle L'unité s'appelle `forgejo_runner.service` — avec un souligné, ce qui explique pourquoi la première recherche n'avait rien trouvé. Elle porte : ```ini User=forgejo_runner SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap ProtectSystem=full ``` Une liste de refus, sans `SystemCallErrorNumber=` : l'action par défaut est de **tuer** le processus, d'où `Bad system call`. **Quel ensemble est coupable reste inconnu.** L'hypothèse « `npm ci` en root appelle `setuid` » ne tient pas : `User=forgejo_runner` dit que le runner n'est pas privilégié, et un npm non privilégié n'appelle pas `setuid`. Plutôt que de deviner à nouveau, le correctif retenu est `SystemCallErrorNumber=EPERM`, qui fait *refuser* les appels filtrés au lieu de tuer — suffisant pour presque tout outil, et le durcissement reste. Ça ne concerne que la e2e : elle doit être sur l'hôte, c'est son intérêt même. ### 3. Le conteneur ne joint pas la forge C'est ce qui a rougi les runs 9 et 10, et c'est nouveau — apparu avec le conteneur : ``` fatal: unable to access 'https://git.lozach.eu/maxime/OrganisateurFamilial/': Failed to connect to git.lozach.eu port 443 after 101 ms: Couldn't connect to server ``` Trois tentatives, trois refus en ~100 ms. Le nom se résout, le TCP est refusé net — ce n'est pas un problème de DNS ni de lenteur. Un job sur le bridge Docker n'atteint pas la forge : pare-feu fermé au sous-réseau du bridge, ou proxy qui n'écoute que sur l'IP publique et hairpin NAT qui ne repasse pas. Les jobs « host » se clonaient très bien, ce qui cadre. Correctif visé : `container.options: --add-host=git.lozach.eu:host-gateway` dans `/var/www/forgejo_runner/config.yml`. **Décidé** : on répare le réseau et `checks` reste en conteneur, plutôt que de le ramener sur l'hôte. L'isolation vis-à-vis de ce que la machine porte était la raison du choix, et le correctif réseau sert tout job conteneurisé à venir. ### 4. Un troisième point, pas encore rencontré `ProtectSystem=full` met `/usr` en lecture seule. Le job e2e appelle `npx playwright install --with-deps chromium`, dont le `--with-deps` est un `apt-get install` : il échouera. Soit on lève la protection, soit on installe les dépendances système de Chromium une fois à la main et on retire le drapeau du workflow — la seconde garde le durcissement et remplace une installation à chaque exécution par une opération unique. ### 5. État Rien à corriger dans le dépôt : les deux workflows sont écrits, et le contenu de `checks` a été répété vert de bout en bout dans l'image du label (79 s, arm64). Les trois lignes restantes du plan d'action sont toutes sur la machine du runner.
Collaborator

Run 35 : un timeout sous charge, pas une régression

Deux tests frontend tombés sur le délai de 5 s :

services/format.test.ts:23        formatDay('2026-08-24', FULL_DAY)
services/relative-day.test.ts:101 dueLabel('2026-08-30')

Des assertions synchrones sur un formateur de dates. Rejouées dans l'image du runner,
sur un export propre de HEAD : les deux fichiers font 25 tests en 66 ms. Et le même
commit était passé au run 34.

Ce qui a bougé, c'est la machine

Entre les deux exécutions, rien d'autre qu'une modification du workflow — qui ne touche pas
aux tests :

run 34 (vert) run 35 (rouge)
backend, tests 3,47 s 14,81 s
backend, collect 61,55 s 10,53 s
frontend, tests 0,97 s 18,01 s

Un facteur 6 à 18, dans les deux sens. Vitest dimensionne son pool sur les cœurs que Node
lui rapporte, et un conteneur sans limite de CPU se voit annoncer tous les cœurs de
l'hôte — y compris ceux qui servent déjà Forgejo et nginx. Les workers s'affament
mutuellement, et un test qui dure moins d'une milliseconde rate une échéance de cinq
secondes.

La contre-épreuve va dans le même sens : la suite frontend plafonnée à 1 CPU passe
entière — 344 tests, 349 ms de temps de test — simplement parce que Node n'y voit qu'un cœur
et vitest ne lance qu'un worker. Je n'ai pas réussi à faire échouer les tests ; j'ai réussi à
les rendre fiables, ce qui pointe la même cause sans la démontrer.

Le plafond retenu, et ce qu'il coûte

Mesuré sur dix cœurs, suite frontend complète :

pool horloge environnement cumulé
défaut (10 workers) 10,91 s 69,5 s
maxWorkers: '50%' 11,85 s 44,0 s
2 workers 15,61 s 22,2 s

Une seconde d'horloge pour la moitié de la contention. Deux workers coûtaient 43 % de plus,
ce qui dépasse le prix du problème.

Le plafond va dans vitest.config.ts, pas dans le workflow : ce qui passe sur le poste
doit passer en CI, comme pour le lint. Le backend n'avait aucune configuration vitest, il en
reçoit une.

Vérifié dans l'image du runner : 426 tests backend, 344 frontend, 344 sous
Pacific/Auckland, lint et typecheck compris.

## Run 35 : un timeout sous charge, pas une régression Deux tests frontend tombés sur le délai de 5 s : ``` services/format.test.ts:23 formatDay('2026-08-24', FULL_DAY) services/relative-day.test.ts:101 dueLabel('2026-08-30') ``` Des assertions **synchrones** sur un formateur de dates. Rejouées dans l'image du runner, sur un export propre de `HEAD` : les deux fichiers font **25 tests en 66 ms**. Et le même commit était passé au run 34. ### Ce qui a bougé, c'est la machine Entre les deux exécutions, rien d'autre qu'une modification du workflow — qui ne touche pas aux tests : | | run 34 (vert) | run 35 (rouge) | |---|---|---| | backend, `tests` | 3,47 s | 14,81 s | | backend, `collect` | 61,55 s | 10,53 s | | frontend, `tests` | 0,97 s | **18,01 s** | Un facteur 6 à 18, dans les deux sens. Vitest dimensionne son pool sur les cœurs que Node lui rapporte, et un conteneur sans limite de CPU se voit annoncer **tous** les cœurs de l'hôte — y compris ceux qui servent déjà Forgejo et nginx. Les workers s'affament mutuellement, et un test qui dure moins d'une milliseconde rate une échéance de cinq secondes. La contre-épreuve va dans le même sens : la suite frontend plafonnée à **1 CPU** passe entière — 344 tests, 349 ms de temps de test — simplement parce que Node n'y voit qu'un cœur et vitest ne lance qu'un worker. Je n'ai pas réussi à faire échouer les tests ; j'ai réussi à les rendre fiables, ce qui pointe la même cause sans la démontrer. ### Le plafond retenu, et ce qu'il coûte Mesuré sur dix cœurs, suite frontend complète : | pool | horloge | environnement cumulé | |---|---|---| | défaut (10 workers) | 10,91 s | 69,5 s | | **`maxWorkers: '50%'`** | **11,85 s** | **44,0 s** | | 2 workers | 15,61 s | 22,2 s | Une seconde d'horloge pour la moitié de la contention. Deux workers coûtaient 43 % de plus, ce qui dépasse le prix du problème. Le plafond va dans `vitest.config.ts`, **pas dans le workflow** : ce qui passe sur le poste doit passer en CI, comme pour le lint. Le backend n'avait aucune configuration vitest, il en reçoit une. Vérifié dans l'image du runner : 426 tests backend, 344 frontend, 344 sous `Pacific/Auckland`, lint et typecheck compris.
Sign in to join this conversation.
No description provided.