CI (GitHub Actions, etc.)

« Ça compile sur ma machine. » Quiconque a rédigé un document LaTeX à plusieurs finit par prononcer cette phrase. Construire en CI (continuous integration, intégration continue), c’est la transformer en quelque chose qu’une machine peut vérifier : à chaque push, un environnement TeX Live vierge récupère le dépôt depuis zéro et répond à votre place à la question de savoir si le PDF se reproduit vraiment. Le TeX Live du coauteur contient une autre version d’un package ; \setmainfont désigne une police qui n’existe que sur votre portable ; le .bbl n’a jamais été validé. Rien que de très banal, et rien qui se reproduise justement sur la machine à l’origine du problème. Cette page part du plus petit workflow GitHub Actions capable de produire un PDF, passe par les trois façons d’installer TeX Live sur un runner, le cache et la diffusion du résultat, et se termine par l’échec le plus déplaisant : la CI est verte et le PDF est cassé.

Pourquoi construire LaTeX en CI ?

Il n’y a qu’une raison : votre propre machine ne fait pas preuve. La sortie d’un document LaTeX n’est pas déterminée par sa seule source. Elle dépend du millésime de TeX Live installé, de la révision de chaque package, des polices enregistrées dans le système, et même d’un vieux .sty traînant quelque part dans TEXINPUTS. « Chez moi ça a compilé » traîne donc toujours une longue clause tacite : « sur mon TeX Live de 2024, avec le fichier de classe que j’ai déposé à la main il y a trois ans ». La CI est la machine qui énonce cette clause à chaque fois. Le job démarre d’un conteneur vide et ne voit rien d’autre que le contenu du dépôt : si un PDF en sort, c’est la preuve que le contenu du dépôt suffit à le produire.

Le plus grand exemple concret de cette idée est arXiv. arXiv ne publie pas le PDF que vous téléversez : il recompile la source LaTeX soumise sur ses propres serveurs. Et les auteurs ne peuvent choisir qu’entre deux versions de TeX Live, chacune figée à un état daté précis. Il est révélateur que la première chose qu’ait faite le plus gros serveur de compilation LaTeX au monde ait été de figer son environnement. La CI est l’outil qui permet d’en faire autant dans son propre dépôt, avec un bénéfice supplémentaire : les coauteurs et relecteurs sans installation de TeX reçoivent toujours le PDF à jour. Le build se décrit dans un fichier YAML placé sous .github/workflows/.

Le plus petit workflow GitHub Actions qui produit un PDF

Trois étapes suffisent : récupérer la source, la compiler, conserver le PDF. Déposez le YAML suivant sous .github/workflows/build.yml et l’affaire est réglée. actions/checkout déploie le dépôt sur le runner, xu-cheng/latex-action compile dans un conteneur où TeX Live est déjà présent, et actions/upload-artifact épingle le PDF obtenu à la page d’exécution du workflow. Le déclencheur est on: [push, pull_request], pour qu’un PDF cassé n’arrive jamais jusqu’à la relecture.

terminal
# .github/workflows/build.yml
name: Build LaTeX
on: [push, pull_request]
permissions:
  contents: read
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: xu-cheng/latex-action@v4
        with:
          root_file: main.tex
      - uses: actions/upload-artifact@v7
        with:
          name: pdf
          path: main.pdf
          if-no-files-found: error

root_file est la seule entrée obligatoire de xu-cheng/latex-action. Ce qui tourne réellement à l’intérieur, c’est latexmk, avec les arguments par défaut -pdf -file-line-error -halt-on-error -interaction=nonstopmode : autrement dit pdfLaTeX, déjà réglé pour s’arrêter dès la première erreur. -file-line-error reformule les erreurs sous la forme file:line: message, ce qui rend un journal de CI nettement plus lisible. Pour changer de moteur, définissez latexmk_use_xelatex: true ou latexmk_use_lualatex: true ; pour figer le millésime de TeX Live, texlive_version. La base est Alpine Linux par défaut et se change avec os: debian. Les paquets système supplémentaires passent par extra_system_packages, et vos propres polices par extra_fonts.

Quand la procédure elle-même s’écarte du standard — un document japonais combinant upLaTeX et dvipdfmx, par exemple —, le plus sûr est de valider un .latexmkrc dans le dépôt. La CI et votre machine lisent alors le même fichier de configuration, et il n’y a plus deux endroits à corriger ; la rédaction de cette configuration relève de la page sur les compilations automatisées. Autre point à retenir si un modèle doit durer : les versions majeures des actions bougent. actions/checkout, par exemple, a changé de comportement en v7 et refuse désormais par défaut de récupérer le code d’une pull request issue d’un fork lorsque le workflow est déclenché par pull_request_target ou workflow_run. Fixez une date annuelle pour relire les README officiels, ou acceptez les pull requests de mise à jour des dépendances.

Trois façons d’installer TeX Live sur le runner

Trois possibilités : laisser faire l’action, installer TeX Live soi-même sur le runner, ou exécuter le job dans une image Docker qui le contient déjà. Le choix dépend du degré de contrôle voulu sur l’environnement et du temps d’attente acceptable à chaque exécution. Car TeX Live est énorme. Mesurée sur une installation locale complète, la version 2024 avec documentation et sources atteint 8,7 Go. L’image texlive/texlive distribuée par l’Island of TeX retire documentation et sources et pèse encore environ 2,5 Go compressés sur Docker Hub. Ce chiffre commande presque tout ce qui suit.

ApprocheOrigine de TeX LiveConvient quand
xu-cheng/latex-actionL’action télécharge elle-même une image Docker contenant TeX LiveOn veut le chemin le plus court vers un build qui marche, configuré par le seul root_file
TeX-Live/setup-texlive-actionInstalle sur le runner via tlmgr et met TEXDIR en cacheOn ne veut que les packages réellement utilisés, ou des runners autres que Linux
texlive/texliveUne image Docker de l’Island of TeX déclarée comme container: du jobOn veut décider soi-même du contenu du conteneur, ou le figer sur un tag daté

texlive/texlive est diffusée à la fois sur Docker Hub et sous registry.gitlab.com/islandoftex/images/texlive, et le tag par défaut correspond au schéma full — documentation et sources en moins. Les variantes -doc, -src et -doc-src existent au besoin, chacune nettement plus lourde. Comme latest est reconstruite chaque semaine, tout ce qui a une échéance gagne à être épinglé sur un tag d’instantané daté du type TL2022-2022-06-05. Pour reproduire un millésime ancien tel quel, des tags historiques comme TL2018-historic sont conservés. Indiquez l’image dans le container: du job et toutes les étapes suivantes s’y exécutent.

terminal
# pin the image; latest is rebuilt weekly
jobs:
  build:
    runs-on: ubuntu-latest
    container: texlive/texlive:latest
    steps:
      - uses: actions/checkout@v7
      - run: latexmk -pdf -halt-on-error -interaction=nonstopmode main.tex
      - uses: actions/upload-artifact@v7
        with:
          name: pdf
          path: main.pdf
          if-no-files-found: error

Mettre en cache l’installation de TeX Live pour réduire l’attente

Avec TeX-Live/setup-texlive-action, le cache est déjà actif. L’entrée cache de l’action vaut true par défaut ; en interne elle appelle @actions/cache et enregistre l’intégralité de TEXDIR. La sauvegarde a lieu lors du post-traitement, une fois le job terminé, si bien que ce qui a été produit pendant la compilation — les caches de polices, par exemple — est repris également. Toute exécution après la première évite donc l’intégralité du téléchargement depuis un miroir tlmgr. On désactive avec cache: false. Une précision : cette action se trouvait autrefois sous teatimeguest/setup-texlive-action et a depuis migré vers l’organisation TeX-Live ; du YAML recopié d’un article ancien ne se résoudra donc pas.

terminal
      - uses: TeX-Live/setup-texlive-action@v4
        with:
          version: 2025
          packages: |
            scheme-basic
            latexmk
            biber
            biblatex
      - run: latexmk -pdf -halt-on-error -interaction=nonstopmode main.tex

L’usage avec cette action consiste à placer scheme-basic dans packages puis à ajouter ce dont on a besoin. Plutôt que de tirer les 8 Go entiers, n’énumérez que ce que vous chargez réellement par \usepackage : biber, biblatex, siunitx, etc. Quand la liste s’allonge, package-file la déporte vers un fichier comme .github/tl_packages ou un motif du type **/DEPENDS.txt. L’entrée version fixe le millésime, ce qui reproduit en miniature ce que fait arXiv : on peut dire « ça compile sur TeX Live 2025 » au lieu de « ça compile sur latest ». Si vous choisissez la voie Docker, chercher à mettre l’image elle-même en cache avec actions/cache est une mauvaise piste : épinglez le tag et laissez le pull du registre s’en charger.

Distribuer le PDF : artifact ou release ?

Ces deux mécanismes évitent d’avoir à dire à un relecteur « installez d’abord TeX Live ». Avec actions/upload-artifact, le PDF est épinglé à la page d’exécution du workflow et quiconque a accès au dépôt peut le télécharger. La rétention suit le réglage du dépôt, avec un plafond de 90 jours. Un petit réglage compte ici plus qu’il n’y paraît : archive vaut true par défaut, l’artifact est donc compressé en zip avant l’envoi et le destinataire récupère un zip plutôt que main.pdf. Avec archive: false, un fichier unique part tel quel, ce qui épargne une manipulation au coauteur.

Pour une version réellement distribuée, une release convient mieux. Les artifacts disparaissent à l’expiration de la rétention ; un PDF attaché à une release, non : il reçoit une URL permanente et reste accessible depuis la page d’accueil du dépôt. La forme habituelle est « pousser un tag de version, obtenir une release », et comme les images de runner Ubuntu hébergées par GitHub embarquent déjà la GitHub CLI (gh), une seule ligne suffit, sans action supplémentaire. Créer une release est toutefois une opération d’écriture : il faut élever permissions: à contents: write et fournir un GH_TOKEN. Gardez le workflow de compilation en contents: read et placez le job de publication dans un fichier distinct.

terminal
# .github/workflows/release.yml
name: Release PDF
on:
  push:
    tags: ["v*"]
permissions:
  contents: write
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: xu-cheng/latex-action@v4
        with:
          root_file: main.tex
      - run: gh release create "$GITHUB_REF_NAME" main.pdf
        env:
          GH_TOKEN: ${{ github.token }}
  • Ajoutez toujours if-no-files-found: error. La valeur par défaut est warn : une faute de frappe dans path: laisse le workflow au vert.
  • Avec archive: false, le PDF part tel quel au lieu d’être enfermé dans un zip (fichiers uniques seulement).
  • Livrez la configuration de compilation (.latexmkrc et compagnie) dans le dépôt : local et CI suivent alors les mêmes étapes et une source de divergence disparaît.
  • Épinglez la version de TeX Live pour tout ce que vous soumettez, via texlive_version, via l’entrée version de setup-texlive-action, ou via un tag d’image daté.
  • Laissez permissions: à contents: read par défaut et ne l’élevez à contents: write que dans le job qui crée une release.

Quand la CI échoue — et quand elle est verte mais le PDF cassé

Ouvrez d’abord le journal et cherchez les lignes commençant par !. Toute erreur LaTeX se signale sous cette forme, elle est donc repérable au milieu de centaines de lignes. Les symptômes courants se ramènent à cinq ou six, et chacun correspond presque terme à terme à sa cause.

Message du journalCe qui s’est réellement passéQue faire
! LaTeX Error: File `...sty' not found.Le TeX Live de la CI ne contient pas ce packageL’ajouter à packages, utiliser extra_system_packages, ou passer à une image en schéma full
! Undefined control sequence.Une faute de frappe dans le document, ou le package fournissant la commande n’a pas été chargéVérifier l’orthographe de la ligne et la liste des \usepackage ; l’erreur doit aussi apparaître en local
! Package fontspec Error: The font "..." cannot be found.Cette police n’est pas dans le conteneur ; en local elle venait du systèmeVerser la police dans le dépôt et la passer par extra_fonts, ou choisir une police fournie avec TeX Live
LaTeX Warning: There were undefined references.Un simple avertissement : le code de sortie est 0 et le PDF conserve des ??Laisser latexmk effectuer les passes nécessaires ; pour faire échouer, filtrer le journal avec grep et sortir en non-zéro
No files were found with the provided pathLe path: fourni à upload-artifact ne correspond pas au nom de sortie réelVérifier où atterrit vraiment le PDF ; sans if-no-files-found: error, l’exécution reste verte
! Emergency stop.TeX est tombé sur son invite interactive et la CI n’a aucun terminal pour répondreAjouter -interaction=nonstopmode ; latex-action le passe par défaut

Un malentendu répandu sur -interaction=nonstopmode mérite d’être levé ici : cette option n’escamote pas les erreurs. Passez par pdflatex -interaction=nonstopmode un document contenant une commande non définie et le code de sortie vaut honnêtement 1. Ce qu’elle fait en revanche, c’est produire quand même un PDF : elle saute le point fautif et va jusqu’au bout du document. Tout ce que fait réellement l’option, c’est renoncer à s’arrêter pour interroger un humain ; aucune information sur le succès ou l’échec n’est perdue. Le même document passé à latexmk -pdf se termine en non-zéro et ne laisse aucun PDF. En CI, c’est ce comportement qu’on veut, et -halt-on-error interrompt en prime dès la première erreur.

Ce qui casse vraiment en silence, ce n’est pas l’erreur mais l’avertissement. Lorsqu’un \ref ou un \cite ne se résout pas, LaTeX se contente de LaTeX Warning: There were undefined references. — et le code de sortie vaut 0. La CI passe au vert et l’artifact est un PDF criblé de ??. Superposez à cela la valeur par défaut warn de if-no-files-found et vous obtenez un workflow dont le path: comporte une faute de frappe, vert d’un bout à l’autre, sans rien produire du tout. Pour pouvoir se fier au vert, il faut au minimum écrire if-no-files-found: error et laisser latexmk déterminer le nombre de passes nécessaires.

Reste le dernier cas : ça compile chez vous, et seule la CI échoue. La cause est presque toujours une dépendance à quelque chose qui n’est pas dans le dépôt. Une image ou un .bbl déjà généré qui n’existe que sur votre disque ; un fichier produit que .gitignore a discrètement avalé ; et, très souvent, la casse d’un nom de fichier. Les systèmes de fichiers par défaut de macOS et de Windows ignorent la casse, si bien que \includegraphics{Figure1} trouve sans peine figure1.pdf sur votre machine — alors que sur le Linux du runner ce sont deux noms distincts. Quand la CI échoue, demandez-vous d’abord si la chose se trouve dans le dépôt. Ce qui est, à l’envers, précisément la valeur de la CI : elle pose cette question à votre place à chaque push.