Ce qui change sur un runner
Le test lui-même ne change pas. Ce qui change, c’est tout ce qui l’entoure, et chacun de ces points a une réponse précise plutôt qu’un haussement d’épaules :
- Il n’y a aucun secret à monter
- Lire une boîte publique ne demande ni clé, ni compte, ni en-tête, donc le côté boîte mail n’ajoute rien à
secrets. Le seul identifiant du job est celui dont votre application a déjà besoin pour envoyer du courrier — SendGrid, Postmark, SES, peu importe — et il appartient à votre application, pas au test. - Le runner doit pouvoir atteindre Internet
- Du HTTPS sortant vers
grabmail.io, et du sortant vers ce qu’utilise votre mailer. Les runners hébergés par GitHub autorisent les deux par défaut ; un runner auto-hébergé derrière un filtre de trafic sortant a besoin d’une règle ajoutée. - Les exécutions se chevauchent
- Deux pull requests, quatre shards, une nouvelle tentative d’un job instable — plusieurs copies du même test lisent du courrier en même temps. Une adresse partagée leur permettrait de lire les codes les unes des autres ; une adresse fraîche par test rend toute cette catégorie de problème impossible.
- Le temps est facturé
- Un test qui attend soixante secondes le courrier, ce n’est pas un problème. Un job qui attend soixante secondes dans chacun de quarante tests, ce sont quarante minutes de runner facturées. Les attentes doivent être bornées, et la suite doit être découpée en shards une fois qu’elle grossit.
Le code de test que ces workflows exécutent est celui du guide Playwright ou du guide Python : une adresse fraîche, une attente avec une échéance, un extracteur ancré sur votre modèle. Rien dedans n’est spécifique à la CI, et c’est précisément le but — les parties spécifiques au runner vivent toutes dans le fichier de workflow.
Le workflow, pour Playwright
Un seul job. Il démarre votre application avec un vrai mailer sortant, attend qu’elle réponde, exécute la suite, et ne conserve le rapport que si quelque chose a échoué.
name: e2e
on:
push:
branches: [main]
pull_request:
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 20 # the whole job, comfortably above every wait inside it
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
# Your application, started the way it runs in staging: a REAL outbound
# mailer. Its credentials are YOUR secret; the mailbox side needs none.
- name: Start the application
run: npm run start:test &
env:
MAILER_API_KEY: ${{ secrets.MAILER_API_KEY }}
APP_URL: http://localhost:3000
- name: Wait for the application
run: npx wait-on --timeout 60000 http://localhost:3000/health
- name: Run the suite
run: npx playwright test
env:
BASE_URL: http://localhost:3000
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7Trois lignes portent l’essentiel. timeout-minutes: 20 est la borne extérieure dans laquelle tout le reste s’emboîte. L’application est démarrée avec ses vrais identifiants de mailer, parce qu’un test qui lit du vrai courrier a besoin que du vrai courrier soit envoyé. Et le rapport n’est téléversé qu’en cas d’échec, avec une rétention courte — une exécution réussie n’a rien qui mérite d’être conservé.
Le workflow, pour pytest
La même forme avec la chaîne d’outils Python : démarrer l’application, attendre son endpoint de santé, exécuter la suite avec un timeout par test au-dessus de l’échéance du courrier, conserver le rapport JUnit en cas d’échec.
name: e2e
on:
push:
branches: [main]
pull_request:
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
- run: pip install -r requirements.txt -r requirements-test.txt
- name: Start the application
run: python -m app.server &
env:
MAILER_API_KEY: ${{ secrets.MAILER_API_KEY }}
APP_URL: http://localhost:8000
- name: Wait for the application
run: |
for i in $(seq 1 60); do
curl -sf http://localhost:8000/health && exit 0
sleep 1
done
echo "application did not come up" >&2; exit 1
- name: Run the suite
run: pytest tests/e2e -q --timeout=120 --junitxml=report.xml
env:
BASE_URL: http://localhost:8000
- uses: actions/upload-artifact@v4
if: failure()
with:
name: pytest-report
path: report.xml--timeout=120 vient du plugin pytest-timeout et constitue le plafond par test ; l’échéance de courrier à l’intérieur de l’utilitaire est de soixante secondes, donc un test qui attend un message puis fait un peu de travail navigateur tient toujours dedans. La boucle de vérification de santé est écrite directement plutôt qu’importée sous forme d’action, parce qu’elle fait huit lignes et qu’il n’y a rien à y mal faire.
L’unique règle réseau
Lire une boîte, c’est une requête HTTPS sortante du runner vers grabmail.io. C’est toute l’empreinte réseau :
- Rien en entrée. Rien ne se connecte au runner. Il n’y a aucun webhook à recevoir, aucun serveur SMTP à faire tourner, aucun port à exposer.
- Aucun SMTP depuis le runner. Le courrier est envoyé par votre application via son fournisseur, par l’API ou l’endpoint SMTP de ce fournisseur — de la même façon qu’en production. Le runner ne parle jamais SMTP lui-même.
- Seul
grabmail.io:443à ajouter sur un runner avec une liste blanche de trafic sortant — en plus de votre fournisseur de messagerie et de votre registre de paquets, dont le job avait déjà besoin.
- uses: step-security/harden-runner@v2
with:
egress-policy: block
allowed-endpoints: >
grabmail.io:443
api.your-mail-provider.example:443
registry.npmjs.org:443Si la suite passe en local et échoue en CI avec une erreur de connexion venant de l’utilitaire, cette règle est la première chose à vérifier, et c’est presque toujours toute la réponse. Un runner auto-hébergé dans un réseau d’entreprise a généralement son trafic HTTPS sortant filtré par nom d’hôte ; le nom d’hôte à autoriser est celui de l’API, et la requête est du simple HTTPS sur le port 443.
Shards, jobs en matrice et nouvelles tentatives
Une fois que la suite est assez lente pour justifier des shards, découpez-la en shards. Playwright répartit une exécution sur plusieurs jobs avec --shard, et comme chaque test ouvre sa propre boîte, les shards n’ont besoin de rien les uns des autres :
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
# ... the same steps as above, then:
- run: npx playwright test --shard=${{ matrix.shard }}/4La même propriété couvre les deux autres façons dont un test finit par s’exécuter deux fois à la fois :
- Deux pull requests en même temps
- Deux jobs, deux ensembles d’adresses aléatoires, aucun chevauchement. Le plafond par client sur l’API est de 1200 requêtes par minute, soit vingt boîtes interrogées une fois par seconde — un job qui interroge une boîte à la fois en est très loin.
- Un job relancé
- Une nouvelle tentative exécute à nouveau le corps du test, qui invente à nouveau une nouvelle adresse. L’ancienne boîte contient toujours l’ancien message pendant 5 jours, et rien ne le lit — la nouvelle tentative ne le voit jamais.
- Les propres
retriesde Playwright - Même chose, un niveau plus bas : chaque tentative exécute à nouveau la fixture. Ne déplacez pas l’adresse dans un
beforeAllpour gagner du temps : c’est exactement le genre de partage qui permet à une tentative de lire le code de la tentative précédente.
Des timeouts qui tiennent dans le job
Il y a quatre horloges, et elles doivent s’emboîter, la plus courte à l’intérieur. Quand ce n’est pas le cas, l’échec est signalé par la mauvaise horloge et pointe vers la mauvaise cause.
| Horloge | Réglée où | Une valeur raisonnable |
|---|---|---|
| Échéance du courrier | Dans l’utilitaire (timeoutMs, timeout=) | 60 s. Le courrier transactionnel arrive en quelques secondes ; une minute couvre une file d’attente lente chez le fournisseur. |
| Timeout du test | playwright.config.ts / --timeout | 120 s. Au-dessus de l’échéance, plus le travail navigateur autour. |
| Timeout du step | timeout-minutes sur le step, le cas échéant | Généralement non défini ; la borne du job suffit. |
| Timeout du job | timeout-minutes sur le job | 20 min. Suffisant pour l’installation, le démarrage, la suite et le téléversement ; assez bas pour qu’une application bloquée ne facture pas une heure. |
Le symptôme d’un emboîtement cassé est particulier : un test qui attend soixante secondes à l’intérieur du timeout de test par défaut de Playwright, de trente secondes, meurt à trente secondes avec un message qui parle du test, à chaque fois, et ne dit rien du courrier. Réglez d’abord le timeout du test, puis tout ce qui l’englobe.
Lire un échec
Une exécution échouée devrait vous dire laquelle de ces trois choses s’est produite — le courrier n’est jamais arrivé, le mauvais courrier est arrivé, ou le code qu’il contenait était faux — sans avoir à relancer quoi que ce soit. Quatre habitudes rendent cela possible :
- Journalisez l’adresse. Le message d’échec de l’utilitaire nomme la boîte sur laquelle il attendait. Affichez-la une fois de plus en haut du test pour qu’elle figure dans le journal du job, même quand la vérification échoue ailleurs.
- Ouvrez la boîte à la main. Les messages restent 5 jours, donc
/inbox/<address>sur ce site montre exactement ce que le runner a vu — ou pas vu — pour le reste de la semaine. C’est l’avantage le plus utile d’une vraie boîte par rapport à une boîte simulée. - Conservez le rapport en cas d’échec. La trace de Playwright montre le clic qui aurait dû envoyer le courrier ; le fichier JUnit montre quel test, et combien de temps il a attendu.
- Vérifiez le statut du service avant de blâmer le test. La page de statut est sondée depuis l’extérieur toutes les deux minutes ; si le courrier entrant était en panne au moment de l’exécution, l’échec était réel, et pas de votre fait.
Nettoyer, en option
Tout expire au bout de 5 jours, que quelqu’un le supprime ou non, donc une exécution qui saute le nettoyage ne coûte rien. Supprimer ce que l’exécution a lu mérite quand même un step, parce que l’échec suivant est alors lu sur une boîte véritablement vide. C’est idempotent — supprimer deux fois répond quand même 200 — donc ça ne peut jamais faire échouer un build à soi seul :
- name: Delete what the run read
if: always()
run: |
for addr in $(cat .e2e-addresses 2>/dev/null); do
curl -sG https://grabmail.io/api/v1/mailbox --data-urlencode "address=$addr" \
| jq -r '.messages[].id' \
| xargs -r -I{} curl -sX DELETE -G "https://grabmail.io/api/v1/message/{}" --data-urlencode "mailbox=$addr" -o /dev/null
doneConfigurez-le en if: always() et ne le rendez jamais obligatoire : un nettoyage qui échoue devrait être un avertissement dans un journal, pas un build rouge.
Avant de considérer que c’est terminé
- Aucun identifiant de boîte mail dans
secrets; seulement la clé de mailer propre à votre application. - Le HTTPS sortant vers
grabmail.ioautorisé, et rien en entrée. - Une adresse fraîche par test, inventée dans le corps du test — sûre sous les shards et les nouvelles tentatives.
- Les quatre timeouts emboîtés : échéance < ; test < ; step < ; job.
- Le rapport téléversé en cas d’échec, avec l’adresse dans le journal.
- Le nettoyage comme un step qui s’exécute toujours et n’est jamais obligatoire.
C’est tout ce que le runner ajoute. La discipline de test sous-jacente — échéance, adresse fraîche, motif ancré — se trouve dans tester un flux de vérification d’e-mail de bout en bout, et les règles d’extraction seules sont dans les codes OTP dans les tests automatisés.
Questions
Dois-je ajouter un secret GrabMail au dépôt ?
Non. Les domaines publics ne demandent ni clé, ni compte, ni en-tête, donc il n’y a rien à ajouter à secrets. Le seul identifiant du workflow est celui que votre application utilise pour envoyer du courrier, dont elle aurait de toute façon besoin pour fonctionner.
Est-ce que ça fonctionne dans un dépôt privé ou sur un runner auto-hébergé ?
Oui. Le runner fait des requêtes HTTPS sortantes vers grabmail.io et rien d’autre ; l’endroit où vous hébergez le runner n’a aucune importance. Sur un runner auto-hébergé derrière un filtre de trafic sortant, autorisez ce nom d’hôte sur le port 443.
Des jobs concurrents vont-ils atteindre la limite de débit ?
Pas en pratique. La limite par adresse est d’une lecture par seconde, que l’utilitaire respecte, et le plafond par client est de 1200 requêtes par minute — vingt boîtes interrogées une fois par seconde, depuis un seul runner. Plusieurs runners sont plusieurs clients. Un 429 est accompagné de Retry-After, et l’utilitaire patiente en conséquence plutôt que d’échouer.
Puis-je exécuter ça selon une planification, comme vérification synthétique de l’inscription en production ?
Oui, et c’est un bon usage : un workflow on: schedule qui s’inscrit avec une adresse fraîche toutes les heures et lit le code prouve tout le chemin de courrier de production, fournisseur compris. Gardez le préfixe de l’adresse reconnaissable pour que les inscriptions soient faciles à purger de votre côté.
Et si mon application refuse les domaines jetables ?
Pointez un domaine qui vous appartient vers le service — un enregistrement MX, aucun compte — et utilisez ce domaine dans la fixture. La configuration prend quelques minutes, et des comptes de test illimités sur un seul domaine montre le schéma dans une suite.
Y a-t-il quoi que ce soit de privé dans la boîte ?
Non. Quiconque connaît une adresse peut la lire, sur un domaine public comme sur le vôtre. Pour une adresse aléatoire contenant un code jetable, c’est sans importance ; pour un environnement de staging qui envoie de vraies données client, c’est rédhibitoire — n’en pointez pas un ici.


