“It compiles on my machine.” Anyone who has written a LaTeX document with a co-author says this sentence eventually. Building in CI (continuous integration) is what turns it into something a machine can check: on every push, a clean TeX Live environment fetches the repository from scratch and answers, on your behalf, whether the PDF really reproduces. Your co-author’s TeX Live carries a different version of a package; \setmainfont points at a font that exists only on your laptop; the .bbl was never committed. All of it is ordinary, and none of it will ever reproduce on the machine that caused it. This page runs from the smallest GitHub Actions workflow that builds a PDF, through the three ways of getting TeX Live onto a runner, caching, and shipping the output, to the nastiest failure of the lot — CI is green and the PDF is broken.
Why build LaTeX in CI at all?
There is one reason: your own machine is not evidence. A LaTeX document’s output is not determined by its source alone. It depends on which year of TeX Live is installed, on the revision of each individual package, on the fonts registered with the system, even on a stale .sty sitting somewhere in TEXINPUTS. So “it built for me” always carries a long unspoken clause: “on my 2024 TeX Live, with the class file I dropped in by hand three years ago.” CI is the machine that makes that clause explicit every time. The job starts from an empty container and can see nothing but what the repository contains — so if a PDF comes out, you have proved that the contents of the repository alone are enough to produce it.
The largest working example of this idea is arXiv. arXiv does not simply publish the PDF you upload — it recompiles the LaTeX source on its own servers. And authors may choose from only two TeX Live versions, each frozen to a specific dated state. It is telling that the first thing the world’s biggest LaTeX build server did was pin its environment. CI is the tool that lets you do the same in your own repository, and it throws in a bonus: co-authors and reviewers with no TeX installation can always be handed the current PDF. You describe the build in a YAML file under .github/workflows/.
The smallest GitHub Actions workflow that builds a PDF
Only three steps are needed: fetch the source, compile it, keep the PDF. Drop the following YAML in as .github/workflows/build.yml and that is the whole thing. actions/checkout unpacks the repository onto the runner, xu-cheng/latex-action compiles inside a container that already has TeX Live, and actions/upload-artifact pins the resulting PDF to the workflow run page. The trigger is on: [push, pull_request] so that a broken PDF never reaches review.
# .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: errorroot_file is the only required input of xu-cheng/latex-action. What actually runs inside is latexmk, with the default arguments -pdf -file-line-error -halt-on-error -interaction=nonstopmode — that is, pdfLaTeX, already configured to stop the moment an error appears. -file-line-error rewrites error reports into the file:line: message form, which makes a CI log far easier to follow. To change engine, set latexmk_use_xelatex: true or latexmk_use_lualatex: true; to pin the TeX Live year, set texlive_version. The base is Alpine Linux by default and can be switched with os: debian. If you need extra system packages use extra_system_packages, and to bring your own fonts along use extra_fonts.
When the procedure itself departs from the standard — a Japanese document combining upLaTeX with dvipdfmx, say — the surest move is to commit a .latexmkrc. CI and your laptop then read the same configuration file, so there is no second place to fix; how to write that configuration belongs to the automated-build page. One more thing worth knowing if a template is meant to last: action major versions move. actions/checkout, for instance, changed behaviour in v7 and now refuses by default to check out fork pull-request code when the workflow was triggered by pull_request_target or workflow_run. Either put a yearly date in the calendar to reread the official READMEs, or let dependency-update pull requests come in.
Three ways to get TeX Live onto the runner
There are three options: let the action handle it, install TeX Live onto the runner yourself, or run the job inside a Docker image that already contains it. Which one you want comes down to how much of the environment you insist on choosing, and how long you are willing to wait on every run. Because TeX Live is enormous. Measured on a local full installation, TeX Live 2024 with documentation and sources comes to 8.7 GB. The texlive/texlive image distributed by the Island of TeX strips documentation and sources out and still weighs roughly 2.5 GB compressed on Docker Hub. That number decides most of what follows.
| Approach | Where TeX Live comes from | Best when |
|---|---|---|
xu-cheng/latex-action | The action pulls its own Docker image with TeX Live inside | You want the shortest path to a working build, configured by root_file alone |
TeX-Live/setup-texlive-action | Installs onto the runner with tlmgr and caches TEXDIR | You want only the packages you actually use, or runners other than Linux |
texlive/texlive | An Island of TeX Docker image named as the job’s container: | You want to decide the container’s contents yourself, or freeze it on a dated tag |
texlive/texlive is distributed both on Docker Hub and as registry.gitlab.com/islandoftex/images/texlive, and the default tag is the full scheme — with documentation and sources removed. -doc, -src and -doc-src flavours exist if you need them, and each is reliably heavier. Since latest is rebuilt every week, anything with a deadline is safer pinned to a dated snapshot tag such as TL2022-2022-06-05. To reproduce an older year as it stood, historic tags like TL2018-historic are kept around. Name the image in the job’s container: and every later step runs inside it.
# 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: errorCaching the TeX Live installation to cut the wait
If you use TeX-Live/setup-texlive-action, caching is on already. The action’s cache input defaults to true; under the hood it calls @actions/cache and stores the whole of TEXDIR. The save happens in the post-job phase after the run finishes, so things generated during the build — font caches, for instance — are carried over too. Every run after the first therefore skips the entire download from a tlmgr mirror. Turn it off with cache: false. One caveat: this action used to live at teatimeguest/setup-texlive-action and has since moved to the TeX-Live organisation, so YAML copied from an older article will not resolve.
- 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.texThe idiom for this action is to put scheme-basic in packages and add what you need on top. Rather than dragging in all 8 GB, list only what you genuinely \usepackage — biber, biblatex, siunitx and so on. Once the list grows unwieldy, move it out with package-file, pointing at a file such as .github/tl_packages or a pattern like **/DEPENDS.txt. The version input pins the year, which is a small-scale reproduction of what arXiv does: you get to say “it builds on TeX Live 2025” rather than “it builds on latest”. If you go the Docker route instead, trying to cache the image itself with actions/cache is the wrong shape of solution — pin the tag and let the registry pull handle it.
Handing the PDF out: artifact or release?
These two are what save you from telling a reviewer “first install TeX Live”. With actions/upload-artifact the PDF is pinned to the workflow run page, and anyone who can see the repository can download it. Retention follows the repository setting, up to a ceiling of 90 days. One small setting matters more than it looks: archive defaults to true, so the artifact is zipped before upload and whoever collects it downloads a zip rather than main.pdf. Set archive: false and a single file goes up as itself, removing one step for the co-author on the other end.
For a version you actually hand out, a release fits better. Artifacts vanish when retention expires; a PDF attached to a release does not, gets a permanent URL, and is reachable from the front page of the repository. The customary shape is “push a version tag, get a release”, and since the GitHub-hosted Ubuntu runner images ship with the GitHub CLI (gh) preinstalled, that takes one line and no extra action. Creating a release is a write operation, though, so permissions: has to be raised to contents: write and a GH_TOKEN supplied. Keep the build workflow at contents: read and put the release job in a separate file.
# .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 }}- Always add
if-no-files-found: error. The default iswarn, so a typo inpath:still ends with a green workflow. - Set
archive: falseand the PDF goes up as itself rather than inside a zip (single files only). - Ship the build configuration (
.latexmkrcand friends) in the repository, so local and CI follow the same steps and one source of divergence disappears. - Pin the TeX Live version for anything you submit — via
texlive_version, via theversioninput ofsetup-texlive-action, or via a dated image tag. - Keep
permissions:atcontents: readby default and raise it tocontents: writeonly in the job that creates a release.
When CI fails — and when CI is green but the PDF is broken
Open the log and look for lines beginning with !. Every LaTeX error announces itself in that form, so it is findable among hundreds of lines. The common symptoms come down to five or six, and each maps almost one-to-one onto its cause.
| What the log says | What actually happened | What to do |
|---|---|---|
! LaTeX Error: File `...sty' not found. | The TeX Live in CI does not have that package | Add it to packages, use extra_system_packages, or switch to a full-scheme image |
! Undefined control sequence. | A typo in the document, or the package providing the command was never loaded | Check the spelling on that line and the \usepackage list; it should reproduce locally too |
! Package fontspec Error: The font "..." cannot be found. | That font is not in the container; locally it came from the operating system | Commit the font and pass it via extra_fonts, or switch to a font that ships with TeX Live |
LaTeX Warning: There were undefined references. | Only a warning: the exit status is 0 and the PDF keeps ?? in it | Let latexmk run the passes it needs; to fail the build, grep the log and exit nonzero |
No files were found with the provided path | The path: given to upload-artifact does not match the real output name | Check where the PDF actually lands; without if-no-files-found: error this still ends green |
! Emergency stop. | TeX dropped to its interactive prompt and CI has no terminal to answer it | Add -interaction=nonstopmode; latex-action passes it by default |
A common misconception about -interaction=nonstopmode is worth clearing up here. The option does not swallow errors. Run a document containing an undefined command through pdflatex -interaction=nonstopmode and the exit status is a perfectly honest 1. What it does do is still write a PDF — it skips past the failing spot and runs to the end of the document. So all the option really does is decline to stop and ask a human; no information about success or failure is lost. Put the same document through latexmk -pdf and it exits nonzero and leaves no PDF behind. In CI that behaviour is the one you want, and -halt-on-error alongside it cuts the run at the first error.
What breaks quietly is not the error but the warning. When a \ref or \cite fails to resolve, all LaTeX emits is LaTeX Warning: There were undefined references. — and the exit status is 0. CI turns green and the artifact is a PDF full of ??. Layer the default warn of if-no-files-found on top of that and you can have a workflow with a mistyped path: finish green from end to end while producing nothing at all. Before you trust the green, at minimum write if-no-files-found: error and let latexmk decide how many passes the document needs.
That leaves the last case: it builds here but only CI fails. The cause is almost always a dependency on something that is not in the repository. An image or a pre-generated .bbl that lives only on your disk; a generated file that .gitignore quietly swallowed; and, very commonly, the case of a filename. The default file systems on macOS and Windows are case-insensitive, so \includegraphics{Figure1} happily finds figure1.pdf on your machine — but on the runner’s Linux those are two different names. When CI fails, ask first whether the thing is in the repository at all. Which is, turned around, exactly the value of CI: it asks that question for you on every push.