CI (GitHub Actions usw.)

„Bei mir kompiliert es.“ Wer ein LaTeX-Dokument gemeinsam mit anderen schreibt, sagt diesen Satz früher oder später. Ein Build in CI (Continuous Integration) macht daraus etwas, das eine Maschine prüfen kann: Bei jedem Push holt eine saubere TeX Live-Umgebung das Repository von Grund auf neu und beantwortet stellvertretend die Frage, ob sich das PDF wirklich reproduzieren lässt. Im TeX Live der Mitautorin steckt eine andere Paketversion; \setmainfont verweist auf eine Schrift, die nur auf dem eigenen Notebook liegt; die .bbl wurde nie committet. Alles alltäglich – und nichts davon lässt sich ausgerechnet auf der Maschine reproduzieren, die es verursacht hat. Diese Seite führt vom kleinsten GitHub Actions-Workflow, der ein PDF baut, über die drei Wege, TeX Live auf einen Runner zu bekommen, Caching und die Auslieferung des Ergebnisses bis zum unangenehmsten Fehler überhaupt: CI ist grün, das PDF ist kaputt.

Warum LaTeX überhaupt in CI bauen?

Es gibt genau einen Grund: der eigene Rechner taugt nicht als Beweis. Die Ausgabe eines LaTeX-Dokuments hängt nicht allein von seinem Quelltext ab, sondern davon, welcher TeX Live-Jahrgang installiert ist, welche Revision jedes einzelne Paket hat, welche Schriften im System registriert sind und ob irgendwo in TEXINPUTS eine veraltete .sty liegt. „Bei mir hat es gebaut“ trägt deshalb immer einen langen ungesagten Zusatz mit sich: „auf meinem TeX Live von 2024, mit der Klassendatei, die ich vor drei Jahren von Hand hineinkopiert habe“. CI ist die Maschine, die diesen Zusatz jedes Mal ausspricht. Der Job startet in einem leeren Container und sieht nichts außer dem Inhalt des Repositorys – kommt also ein PDF heraus, ist bewiesen, dass allein der Repository-Inhalt dafür genügt.

Das größte praktische Beispiel für diesen Gedanken ist arXiv. arXiv veröffentlicht nicht einfach das hochgeladene PDF, sondern kompiliert den eingereichten LaTeX-Quelltext auf eigenen Servern neu. Und zur Wahl stehen immer nur zwei TeX Live-Versionen, jede auf einen bestimmten Datumsstand eingefroren. Es ist bezeichnend, dass der größte LaTeX-Build-Server der Welt als Erstes seine Umgebung festgenagelt hat. CI ist das Werkzeug, mit dem sich dasselbe im eigenen Repository erreichen lässt – mit einem Nebengewinn: Mitautorinnen, Mitautoren und Gutachter ohne TeX-Installation bekommen jederzeit das aktuelle PDF. Beschrieben wird der Build in einer YAML-Datei unter .github/workflows/.

Der kleinste GitHub Actions-Workflow, der ein PDF baut

Nötig sind nur drei Schritte: Quelltext holen, kompilieren, PDF aufbewahren. Das folgende YAML als .github/workflows/build.yml ablegen – mehr ist es nicht. actions/checkout entpackt das Repository auf den Runner, xu-cheng/latex-action kompiliert in einem Container, in dem TeX Live bereits liegt, und actions/upload-artifact heftet das entstandene PDF an die Seite des Workflow-Laufs. Als Auslöser dient on: [push, pull_request], damit ein defektes PDF gar nicht erst ins Review gelangt.

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 ist die einzige Pflichteingabe von xu-cheng/latex-action. Intern läuft tatsächlich latexmk, mit den Standardargumenten -pdf -file-line-error -halt-on-error -interaction=nonstopmode – also pdfLaTeX, von vornherein so eingestellt, dass beim ersten Fehler abgebrochen wird. -file-line-error bringt Fehlermeldungen in die Form file:line: message, was das Lesen eines CI-Logs erheblich erleichtert. Für einen anderen Engine latexmk_use_xelatex: true oder latexmk_use_lualatex: true setzen, für einen festen TeX Live-Jahrgang texlive_version. Die Basis ist standardmäßig Alpine Linux und lässt sich mit os: debian umstellen. Zusätzliche Systempakete kommen über extra_system_packages, eigene Schriften über extra_fonts hinein.

Weicht der Ablauf selbst vom Standard ab – etwa ein japanisches Dokument aus upLaTeX und dvipdfmx –, ist es am sichersten, eine .latexmkrc mit ins Repository zu legen. CI und der eigene Rechner lesen dann dieselbe Konfigurationsdatei, und es gibt keine zweite Stelle zu pflegen; wie diese Konfiguration aussieht, gehört auf die Seite zu automatisierten Builds. Und noch etwas, falls eine Vorlage lange halten soll: Action-Hauptversionen bewegen sich. actions/checkout etwa hat sein Verhalten in v7 geändert und checkt Fork-Pull-Request-Code standardmäßig nicht mehr aus, wenn der Workflow durch pull_request_target oder workflow_run ausgelöst wurde. Entweder einen jährlichen Termin zum Nachlesen der offiziellen READMEs setzen oder Pull Requests zur Aktualisierung der Abhängigkeiten zulassen.

Drei Wege, TeX Live auf den Runner zu bekommen

Es gibt drei Möglichkeiten: die Action machen lassen, TeX Live selbst auf dem Runner installieren oder den Job in einem Docker-Image laufen lassen, das es bereits enthält. Welche davon passt, hängt davon ab, wie viel von der Umgebung selbst bestimmt werden soll und wie lange bei jedem Lauf gewartet werden darf. Denn TeX Live ist riesig. Eine lokale Vollinstallation von TeX Live 2024 samt Dokumentation und Quellen bringt gemessene 8,7 GB auf die Waage. Das von der Island of TeX verteilte Image texlive/texlive lässt Dokumentation und Quellen weg und wiegt auf Docker Hub komprimiert immer noch rund 2,5 GB. Diese Zahl entscheidet fast alles Weitere.

AnsatzWoher TeX Live kommtGeeignet, wenn
xu-cheng/latex-actionDie Action zieht selbst ein Docker-Image mit TeX LiveDer kürzeste Weg zu einem laufenden Build zählt, konfiguriert allein über root_file
TeX-Live/setup-texlive-actionInstalliert per tlmgr auf dem Runner und cacht TEXDIRNur die tatsächlich genutzten Pakete sollen hinein, oder es werden Runner außerhalb von Linux gebraucht
texlive/texliveEin Docker-Image der Island of TeX als container: des JobsDer Inhalt des Containers soll selbst bestimmt oder über einen datierten Tag eingefroren werden

texlive/texlive wird sowohl über Docker Hub als auch als registry.gitlab.com/islandoftex/images/texlive verteilt, und der Standard-Tag entspricht dem full-Schema – allerdings ohne Dokumentation und Quellen. Die Varianten -doc, -src und -doc-src gibt es bei Bedarf, jede zuverlässig schwerer. Da latest wöchentlich neu gebaut wird, ist alles mit Abgabetermin sicherer auf einen datierten Snapshot-Tag wie TL2022-2022-06-05 festgelegt. Um einen früheren Jahrgang unverändert zu reproduzieren, stehen historische Tags wie TL2018-historic bereit. Wird das Image als container: des Jobs eingetragen, laufen alle folgenden Schritte darin.

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

Die TeX Live-Installation cachen und Wartezeit sparen

Mit TeX-Live/setup-texlive-action ist Caching bereits aktiv. Die Eingabe cache der Action hat den Standardwert true; intern ruft sie @actions/cache auf und legt das gesamte TEXDIR ab. Gespeichert wird in der Nachbereitungsphase nach Ende des Jobs, sodass auch während des Builds Erzeugtes – etwa Font-Caches – mit übernommen wird. Jeder Lauf nach dem ersten spart damit den vollständigen Download von einem tlmgr-Mirror. Abschalten lässt sich das mit cache: false. Ein Hinweis: Diese Action lag früher unter teatimeguest/setup-texlive-action und ist inzwischen in die Organisation TeX-Live umgezogen – aus älteren Artikeln kopiertes YAML löst sich also nicht mehr auf.

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

Der übliche Umgang mit dieser Action besteht darin, scheme-basic in packages zu setzen und darauf aufzubauen. Statt alle 8 GB heranzuziehen, wird nur aufgeführt, was tatsächlich per \usepackage gebraucht wird – biber, biblatex, siunitx und Ähnliches. Wird die Liste zu lang, lagert package-file sie aus und verweist etwa auf .github/tl_packages oder ein Muster wie **/DEPENDS.txt. Die Eingabe version legt den Jahrgang fest und reproduziert damit im Kleinen, was arXiv tut: Statt „es baut auf latest“ lässt sich sagen „es baut auf TeX Live 2025“. Wer stattdessen den Docker-Weg geht, sollte nicht versuchen, das Image selbst mit actions/cache zu cachen – den Tag festlegen und den Pull der Registry überlassen ist der geradere Weg.

Das PDF weitergeben: Artifact oder Release?

Diese beiden ersparen den Satz „Installieren Sie bitte zuerst TeX Live“ gegenüber Gutachtern. Mit actions/upload-artifact hängt das PDF an der Seite des Workflow-Laufs, und jede Person mit Zugriff auf das Repository kann es herunterladen. Die Aufbewahrung richtet sich nach der Repository-Einstellung, mit einer Obergrenze von 90 Tagen. Eine kleine Einstellung wiegt dabei schwerer, als sie aussieht: archive steht standardmäßig auf true, das Artifact wird also vor dem Upload gezippt, und am anderen Ende landet ein Zip statt main.pdf. Mit archive: false geht eine einzelne Datei unverändert hoch – ein Handgriff weniger für die Mitautorin.

Für eine Fassung, die tatsächlich weitergegeben wird, passt ein Release besser. Artifacts verschwinden mit Ablauf der Aufbewahrungsfrist; ein an ein Release gehängtes PDF nicht, es bekommt eine dauerhafte URL und ist von der Startseite des Repositorys aus erreichbar. Üblich ist die Form „Versions-Tag pushen, Release erhalten“, und da die von GitHub gehosteten Ubuntu-Runner-Images die GitHub CLI (gh) bereits mitbringen, genügt dafür eine Zeile ohne zusätzliche Action. Das Anlegen eines Release ist allerdings ein Schreibvorgang, also muss permissions: auf contents: write angehoben und ein GH_TOKEN übergeben werden. Der Build-Workflow bleibt besser bei contents: read, der Release-Job gehört in eine eigene Datei.

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 }}
  • Immer if-no-files-found: error setzen. Der Standard ist warn, ein Tippfehler in path: endet also trotzdem mit grünem Workflow.
  • Mit archive: false geht das PDF unverpackt statt im Zip hoch (nur für einzelne Dateien).
  • Die Build-Konfiguration (.latexmkrc und Ähnliches) gehört ins Repository, damit lokal und CI dieselben Schritte gehen und eine Abweichungsquelle entfällt.
  • Für alles, was eingereicht wird, die TeX Live-Version festlegen – über texlive_version, über die Eingabe version von setup-texlive-action oder über einen datierten Image-Tag.
  • permissions: standardmäßig auf contents: read lassen und nur im Job, der ein Release anlegt, auf contents: write anheben.

Wenn CI fehlschlägt – und wenn CI grün ist, das PDF aber kaputt

Zuerst das Log öffnen und nach Zeilen suchen, die mit ! beginnen. Jeder LaTeX-Fehler meldet sich in dieser Form, er ist also auch unter Hunderten von Zeilen auffindbar. Die häufigen Symptome lassen sich auf fünf oder sechs eingrenzen, und jedes lässt sich fast eins zu eins einer Ursache zuordnen.

Meldung im LogWas tatsächlich passiert istWas zu tun ist
! LaTeX Error: File `...sty' not found.Das TeX Live in CI enthält dieses Paket nichtIn packages aufnehmen, extra_system_packages nutzen oder auf ein Image mit full-Schema wechseln
! Undefined control sequence.Ein Tippfehler im Dokument, oder das Paket mit dem Befehl wurde nie geladenSchreibweise in dieser Zeile und die \usepackage-Liste prüfen; der Fehler sollte auch lokal auftreten
! Package fontspec Error: The font "..." cannot be found.Diese Schrift liegt nicht im Container; lokal kam sie vom BetriebssystemDie Schrift ins Repository legen und über extra_fonts übergeben oder auf eine mit TeX Live gelieferte Schrift wechseln
LaTeX Warning: There were undefined references.Nur eine Warnung: Der Exit-Status ist 0, und im PDF bleiben ?? stehenlatexmk die nötigen Durchläufe machen lassen; für einen Fehlschlag das Log mit grep prüfen und ungleich null beenden
No files were found with the provided pathDer an upload-artifact übergebene path: passt nicht zum tatsächlichen AusgabenamenPrüfen, wo das PDF wirklich landet; ohne if-no-files-found: error endet der Lauf trotzdem grün
! Emergency stop.TeX ist in seine interaktive Eingabeaufforderung gefallen, und CI hat kein Terminal zum Antworten-interaction=nonstopmode ergänzen; latex-action übergibt es standardmäßig

Ein verbreitetes Missverständnis zu -interaction=nonstopmode sei hier ausgeräumt: Die Option verschluckt keine Fehler. Läuft ein Dokument mit einem undefinierten Befehl durch pdflatex -interaction=nonstopmode, ist der Exit-Status ehrlich 1. Was sie sehr wohl tut: Sie schreibt trotzdem ein PDF – sie überspringt die fehlerhafte Stelle und läuft bis zum Ende des Dokuments durch. Die Option verzichtet also lediglich darauf, anzuhalten und einen Menschen zu fragen; über Erfolg oder Misserfolg geht keine Information verloren. Dasselbe Dokument durch latexmk -pdf geschickt, endet ungleich null und hinterlässt kein PDF. In CI ist genau dieses Verhalten erwünscht, und -halt-on-error bricht zusätzlich beim ersten Fehler ab.

Was wirklich lautlos kaputtgeht, ist nicht der Fehler, sondern die Warnung. Lässt sich ein \ref oder \cite nicht auflösen, gibt LaTeX nur LaTeX Warning: There were undefined references. aus – und der Exit-Status ist 0. CI wird grün, und das Artifact ist ein PDF voller ??. Kommt der Standardwert warn von if-no-files-found hinzu, kann ein Workflow mit vertipptem path: von Anfang bis Ende grün durchlaufen und dabei überhaupt nichts hinterlassen. Wer dem Grün trauen will, schreibt mindestens if-no-files-found: error und überlässt latexmk die Entscheidung, wie viele Durchläufe das Dokument braucht.

Bleibt der letzte Fall: Es baut lokal, und nur CI scheitert. Die Ursache ist fast immer eine Abhängigkeit von etwas, das nicht im Repository liegt. Ein Bild oder eine fertig erzeugte .bbl, die es nur auf der eigenen Platte gibt; eine erzeugte Datei, die .gitignore stillschweigend geschluckt hat; und sehr häufig die Groß- und Kleinschreibung eines Dateinamens. Die Standard-Dateisysteme von macOS und Windows unterscheiden nicht zwischen Groß- und Kleinschreibung, \includegraphics{Figure1} findet also lokal problemlos figure1.pdf – auf dem Linux des Runners sind das zwei verschiedene Namen. Bei einem CI-Fehlschlag lautet die erste Frage: Liegt die Sache überhaupt im Repository? Und genau darin besteht, umgekehrt betrachtet, der Wert von CI – sie stellt diese Frage bei jedem Push.