Building LaTeX documents on GitHub
Do you have LaTeX projects on GitHub? Ever wondered how to get GitHub to build them automatically? Here’s how I do it.

Image credits: TeXLive logo, GitHub logo, PDF file icon
A geeky workflow for LaTeX documents
I’m a geek. More precisely, I’m a physicist. So I’m probably even geekier than your standard geek.
Now, when physicists write documents, such as papers, letters, or slides for talks, they tend to use LaTeX. I’m no exception. After all, the layout is automatic, the mathematics looks great, and I get to write code to construct my documents. What’s there not to like?
When creating the slides for my talks, I use
the beamer class. It’s become the standard
these days when writing talks in LaTeX.1 Of course,
because LaTeX is code, I put my work into Git and push it to an online Git
service such as GitLab or GitHub. When using GitHub,
I build the document using a GitHub Actions
workflow. This way, the document
builds in a “pristine” environment, and helps me spot issues that might not
be obvious when working only on my development system.
A version lag in Ubuntu’s TeXLive packages
In one recent project, I was building the slides on GitHub by installing the
standard Ubuntu TeXLive LaTeX packages and then
running latexmk. The problem here is that the Ubuntu LaTeX packages
aren’t as up to date as one might like them to be. On GitHub, using for
instance the ubuntu-22.04 Docker image,2 one has access to
a TeXLive version from 2021. That’s somewhat … old. I needed a recent
TeXLive distribution.
But how best to get a recent TeXLive? Starting from a stock Ubuntu image and then installing TeXLive is a no-goer, as anyone who has installed a full TeXLive distribution will know. This process can potentially take hours only to download the individual TeXLive packages. That definitely won’t work in a GitHub-CI build: the process will time out before any document has been built. Also, getting feedback on broken builds would take forever, thus reducing the usefulness of building the project on GitHub.
Another possibility would be to use either the small or medium TeXLive distribution, installing any missing packages as necessary. This would be a lot of work trying to find out which packages are missing. I’d have to update the CI config, wait for a build to run (which involves a long installation step, so it takes a while), see if the build fails, read the log output to find the missing package, add it to the CI config, and try everything again. Future package additions to the document would also make the build fail if I’m not vigilant in keeping the CI config up to date. Not an optimal situation.
A LaTeX-specific GitHub action
Surely
there’s a better way? Yes, there is. Introducing the
latex-action GitHub action.
It’s a
[…] GitHub Action that compiles LaTeX documents to PDF using a complete TeXLive environment.
That’s just what we’re after!
The action provides many TeXLive versions to choose from, from 2020 up to the latest version as of writing, 2025. You can also choose between Alpine or Debian Linux as the base image, giving you some flexibility about what other software you might want to install. There are other options available to handle paths, compilation settings, etc. Check out the action’s documentation for more details.
Sounds great! Let’s begin!
A worked example
Using a simple example for illustration, I’ll first describe how to build a
LaTeX document within a GitHub workflow using the stock Ubuntu images. Then
I’ll show how to migrate to a workflow using the latex-action action.
We’ll construct a short beamer document that’s got a couple of slides in it with just enough complexity to make it a bit interesting. For those who are interested, the document introduces the basics of the discrete Fourier transform.
Building the document locally
Imagine we have a local Git repository called discrete-fourier-transform
with a file containing the following LaTeX code:3
\documentclass[t,aspectratio=169]{beamer}
\usetheme{metropolis}
\title{Briefly introducing the discrete Fourier transform}
\author{Dorotea Avra}
\begin{document}
\maketitle
\begin{frame}{Background}
\begin{itemize}
\item From Wikipedia:\footnote{\url{https://en.wikipedia.org/wiki/Discrete_Fourier_transform}}
\end{itemize}
\begin{quotation}
the discrete Fourier transform is a discrete version of the Fourier
transform that converts a finite sequence of equally-spaced samples of a
function to a same-length sequence of equally spaced samples of the
discrete-time Fourier transform [\ldots].
\end{quotation}
\end{frame}
\begin{frame}{Background}
\begin{itemize}
\item If data sets contain periodic oscillations, one can analyse
them with ``spectral methods'', a.k.a. ``Fourier transform
methods''.
\item Sometimes it is easier or more useful to analyse and process
data in the frequency domain than it is in the time domain.
\item The discrete Fourier transform is a numerical method to
calculate the Fourier transform of a data set on a computer.
\end{itemize}
\end{frame}
\begin{frame}{Discrete Fourier transformation definition}
Consider a vector of $N$ evenly-spaced time series
points,\footnote{Discussion based on Chapter 5.2 Spectral Analysis from
\emph{Numerical Methods for Physics} by Alejandro L.\ Garcia, 1994.}
\begin{equation}
\mathbf{y} = [y_1, y_2, \ldots, y_N]
\end{equation}
The data are sampled every $\tau$ seconds, thus giving us a list of time
points defined by:
\begin{equation}
t_i = \tau (i - 1)
\end{equation}
where $i$ is the 1-based index of the input vector.
\end{frame}
\begin{frame}{Discrete Fourier transformation definition}
Given the vector of data points, $\mathbf{y}$, we can define its discrete
Fourier transform, $\mathbf{Y}$, as a vector:
\begin{equation}
Y_{k+1} = \sum_{j=0}^{N-1} y_{j+1} e^{-2 \pi i j k / N}
\end{equation}
where $i = \sqrt{-1}$. Note that $j$ and $k$ start at zero, whereas the
vector uses a 1-based index.
\end{frame}
\begin{frame}{Example}
Following the discrete Fourier transform discussion in
Garcia,\footnote{Chapter 5.2 Spectral Analysis from \emph{Numerical Methods for
Physics} by Alejandro L.\ Garcia, 1994.} using a sampling interval of
$\tau = 1$, $N = 50$ data points, a signal frequency of $f_s = 0.2$, and
a phase of $\phi_s = 0$ we obtain the following graph:
\centerline{
\includegraphics[width=0.5\textwidth]{dft-sine-wave.png}
}
\end{frame}
\end{document}
Assuming that you have LaTeX installed on your local machine, and that the
code is saved in a file called discrete-fourier-transform.tex, you can
build the document yourself by running latexmk in the
discrete-fourier-transform directory. This will create a PDF file called
discrete-fourier-transform.pdf.
Here’s the output I got for the title page:

That looks good. With this foundation in place, assume that we’ve also created a corresponding repository on GitHub and have pushed the code upstream. If you want to follow along at home, or simply don’t want to copy-and-paste the code from above, you can clone the repository I created.
Getting GitHub to build the document for us
With a GitHub repository on hand, we can get GitHub to build our slides for
us. To do that, we need to create a YAML configuration file in the location
that GitHub expects to find continuous
integration tasks,
namely, .gitlab/workflows. Let’s create the directory:
$ mkdir -p .github/workflows
Using your favourite editor, create a file called ci.yml within that
directory, and fill it with these contents:
name: Build slides in CI
on:
[push, pull_request]
jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: sudo apt install texlive-latex-recommended texlive-latex-extra texlive-luatex latexmk
- name: Build slides
run: latexmk
In GitHub parlance, this file is called a workflow.
We denote the workflow’s name by the value of the name keyword given at
the top level.
The on keyword specifies which events will trigger a workflow. In the
case here, we’ve specified that the workflow should run on pushes to any
branch or on any pull requests. It’s possible to make these settings more
specific, but let’s keep things simple.
Next up is the jobs keyword, which defines the jobs to run within the
workflow. There can be many jobs in a workflow, but in our case, we only
need one.
The keyword in the level under jobs defines a block of job-related tasks
as well as the job’s name. The single job we defined in the workflow above
is called build.
We’ve configured the build job to run on a host using Ubuntu 22.04 as its
base Docker image via the runs-on keyword. You might wonder why I didn’t
use the other available Ubuntu version (24.04), a.k.a. ubuntu-latest.
Well, I had repository lookup errors when trying to install the required
LaTeX packages. Hence, I reverted to the older Ubuntu version where the
installation worked as expected. Stability matters. Especially when
presenting worked examples.
The next keyword is called steps and defines the individual steps that the
job should run.
The first step checks out our repository into our build host with the
actions/checkout@4 GitHub action. The steps thereafter run shell commands
via the run keyword. You can also give each step a name, as we’ve done
here, so that they are easier to find within the GitHub Actions tab.
Workflows appear in the Actions tab on the GitHub repository project page. I’m still a bit confused about how the naming works here. It seems that one combines actions into a workflow. However, the overarching term is GitHub Actions, under which one defines workflows. These then contain actions. It seems a bit circular to me, but I simply might not have understood the concept yet.
Anyway, the subsequent steps in this job install the relevant Ubuntu TeXLive
packages and build the slides with latexmk.
To get GitHub to follow these instructions and thus build the document, we need to add it to our repository and commit that change:
$ git add .github/workflows/ci.yml
$ git commit
[main bc389e5] Build document using GitHub's CI system
Date: Sun Feb 8 21:49:21 2026 +0100
1 file changed, 17 insertions(+)
create mode 100644 .github/workflows/ci.yml
We push this change upstream to get GitHub to build our document.
$ git push
Enumerating objects: 6, done.
Counting objects: 100% (6/6), done.
Delta compression using up to 4 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (5/5), 663 bytes | 331.00 KiB/s, done.
Total 5 (delta 1), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (1/1), completed with 1 local object.
To github.com:paultcochrane/discrete-fourier-transform.git
315cf5a..bc389e5 main -> main
Opening the Actions tab in our GitHub discrete-fourier-transform project,
we see a green tick next to the workflow run. This is a good sign: the
workflow ran successfully. The workflow run’s title is the commit message’s
subject.

Clicking on the workflow run’s title, we’re taken to a page with further details:

Within the grey box on this page, we see the name of the job we ran:
build. Clicking on the smaller white box for the job, we’re taken to
the details for the job:

Here we see the steps that the job took to build everything. If we click on the “Build slides” step, the section expands, and we see its build log. If you’re following along at home, you’ll see lots of LaTeX build information. Scrolling to the end, you’ll see output like this:

The important bits are at the end, in particular these lines:
Output written on discrete-fourier-transform.pdf (6 pages, 143709 bytes).
Transcript written on discrete-fourier-transform.log.
Latexmk: Log file says output to 'discrete-fourier-transform.pdf'
Latexmk: Examining 'discrete-fourier-transform.log'
=== TeX engine is 'LuaHBTeX'
Latexmk: All targets (discrete-fourier-transform.pdf) are up-to-date
where we find out that the document was written to a file called
discrete-fourier-transform.pdf and that all targets are up to date.
Awesome!
Archiving artefacts from the build
Ok, so the build looks good, but does the output of that build also look good? We can’t tell right now because we have no way of viewing it. GitHub built it, but deleted it as soon as the job finished.
That’s … suboptimal. We want to check the output as well. How to do
that? For this task, we want to archive the build artefacts, which we can
do with the actions/upload-artifact@v4 GitHub
action.
To archive our GitHub-built PDF file, we append the following step to those
defined in the build job in our ci.yml workflow file:
- name: Archive built PDF document
uses: actions/upload-artifact@v4
with:
name: dft-slides
path: discrete-fourier-transform.pdf
Now commit this change and push it:
$ git commit .github/workflows/ci.yml
[main d5ec2be] Archive built PDF document as artefact
Date: Mon Feb 9 14:04:58 2026 +0100
1 file changed, 6 insertions(+)
$ git push
Enumerating objects: 9, done.
Counting objects: 100% (9/9), done.
Delta compression using up to 4 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (5/5), 585 bytes | 585.00 KiB/s, done.
Total 5 (delta 2), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (2/2), completed with 2 local objects.
To github.com:paultcochrane/discrete-fourier-transform.git
bc389e5..d5ec2be main -> main
Revisiting the details page for the build job:

we see a new step appear after “Build slides” called “Archive built PDF document”. Clicking on this step, we see its log output:

This part of the log output is interesting:
Artifact dft-slides.zip successfully finalized. Artifact ID 5432273684
Artifact dft-slides has been successfully uploaded! Final size is 137788 bytes. Artifact ID is 5432273684
Artifact download URL: https://github.com/paultcochrane/discrete-fourier-transform/actions/runs/21826316227/artifacts/5432273684
because it shows that the build artefact (called dft-slides.zip) was
created successfully, and we see a link where we can download it.
Of course, you could click on this link to download the archived artefacts. However, a simpler way to access build artefacts is to look at the overview page for a given workflow run. There, you’ll see a new section, called “Artifacts”:4

From here, you can click on the little download button on the right-hand
side. This will download the .zip file that the workflow run created.
If you download that file now and open it, you’ll find the document that GitHub built:

Now things are looking good!
Finding font failures
Not very surprisingly, this result looks much the same as our earlier result. I say much the same because the results are not identical. In particular, the fonts are different. It seems that some fonts have been replaced by default ones. A quick look at the “Build slides” log output reveals:
luaotfload | db : Reload initiated (formats: otf,ttf,ttc); reason: Font "Fira Sans Light" not found.
Package beamerthememetropolis Warning: Could not find Fira Sans fonts on
input line 95.
So, the Fira Sans font that the metropolis theme needs isn’t available.
This is a secondary issue that we want to fix by using an up-to-date TeXLive
distribution.
Migrating to latex-action
Ok, we’ve got a document built on GitHub, and it’s almost producing the PDF output we expect. Now we need to get around the issue of using an outdated TeXLive distribution. Instead of using a version from 2021, we want to use a version from at least 2025.
This is what we really came here for: to see the latex-action action in
action.
To be honest, the change is very simple: remove the “Install dependencies” step and swap out the current “Build slides” step with this YAML code:
- name: Build slides
uses: xu-cheng/latex-action@v4
with:
root_file: discrete-fourier-transform.tex
texlive_version: 2025
latexmk_use_lualatex: true
The new step uses the same name as the step it replaced, but now we’re
using a GitHub action via the uses keyword instead of running latexmk.
We’re also being careful to specify a version. Theoretically, it should be
possible to do without the @v4 suffix, but specifying an explicit version
is good practice. This way, we ensure builds are more reproducible. Were
we to leave out the version suffix, there could be future changes to
latex-action that break an otherwise functioning build. Of course,
updating to a new version would be a good idea once it exists, but we need
to check that it works as expected before committing to it.
We configure options to the latex-action GitHub action within the with
keyword block. The root_file option specifies the main file that builds
the entire document. The texlive_version option specifies the TeXLive
version we want to use, i.e. 2025. We also want to use LuaLaTeX when
running the latexmk command, so that we get the full power of a modern
LaTeX engine. Hence, we set latexmk_use_lualatex to true.
What’s the effect of this change? Well, instead of pulling in an Ubuntu Docker image and then installing the Ubuntu TeXLive packages, we now grab a Docker image with TeXLive pre-installed and use that as our build host. This gives us a complete TeXLive environment from the get-go.
Let’s see how we get on. Doing our commit and push dance:
$ git commit .github/workflows/ci.yml
[main bbe6dba] Replace Ubuntu TeXLive with latex-action TeXLive
Date: Mon Feb 9 14:55:41 2026 +0100
1 file changed, 5 insertions(+), 4 deletions(-)
$ git push
Enumerating objects: 9, done.
Counting objects: 100% (9/9), done.
Delta compression using up to 4 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (5/5), 562 bytes | 562.00 KiB/s, done.
Total 5 (delta 2), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (2/2), completed with 2 local objects.
To github.com:paultcochrane/discrete-fourier-transform.git
d5ec2be..bbe6dba main -> main
and letting GitHub do its thing, we find that everything builds successfully:

That’s a good sign.
If we now download our archived artefacts from the workflow overview page (note that the file size is also smaller):

we see that the font issue has been corrected:

And now we’re using an up-to-date TeXLive version! Yay! ![]()
Where to from here?
And that’s it really. Of course, there are more things you could do to extend and customise the build process.
One thing you might like to try is to add the action-gh-release GitHub
action. This action
automates creating releases according to Git tags. Your Git project will
then host any PDF documents you deem worthy of a versioned release (and
hence a Git tag) on the repository’s project page. If you want to upload
draft releases, that’s also possible. This way, you can show users current
development-grade releases of your LaTeX documents.
The sky’s the limit! Have fun!
Addendum: metropolis and moloch woes
One other incentive I had to use the latex-action GitHub action was a
desire to replace the metropolis beamer
theme with
moloch.
I like metropolis. It’s clean and minimalistic, and looks rather stylish.
Sadly, it’s now unmaintained, and the last release to
CTAN was from 2017. It’s been
replaced by its fork, a project called moloch. Its
internals are cleaner, it fixes some bugs in the metropolis theme, and it
is less fragile to internal changes in beamer itself.
Unfortunately, moloch is only available in recent TeXLive versions (it was
first released in 2024) and hence isn’t available in the Ubuntu TeXLive
packages on GitHub. Hence, my wish to migrate to a GitHub action providing
an up-to-date TeXLive version.
Implementing this change while maintaining the same look led down rather a
deep rabbit hole. Regrettably, I wasn’t able to solve all the problems that
appeared. Thus, I decided against introducing moloch as an additional
change in the main part of this article.
One major blocking issue I had was with fonts. By default, metropolis
uses the Fira Sans font; moloch doesn’t: it seems to use a mixture of the
Computer Modern and Latin Modern fonts. Using Fira Sans with moloch is
simple enough: it’s a configuration option added in the document preamble.
However, this also changed the font used for mathematics, and I couldn’t get
that to look right. For instance, bold mathematics were shown with a serif
font, whereas metropolis used a sans-serif font without problems. Using
either the Fira Math or firamath-otf packages bundled with TeXLive didn’t
help, because they currently only provide the regular typeface. The lighter
typefaces aren’t yet bundled with TeXLive, thus I’m hoping a future TeXLive
update might help this situation.
Oh well. Ya can’t win every day! I’m going to stick to metropolis for
the time being. It still does the job well and works (for my purposes) with
the current TeXLive version.
Once I realised that this detail wasn’t necessary for what I wanted to
introduce in the article, I decided to leave it out of the main text. I’m
guessing no one missed it but me! ![]()
-
Does anyone remember the prosper or foiltex classes? Wow, it seems like aeons ago that I was using them. I think I’m showing my age… ↩
-
I’m aware that the
ubuntu-24.04a.k.aubuntu-latestDocker image is available. However, I got errors when fetching packages. Thus, for this article, and the sake of stability for anyone following along at home, I stuck withubuntu-22.04, where I knew that everything worked. ↩ -
Dorotea Avra is not the name of a real person. I generated it using a random name generation site. ↩
-
Note that American English spells this word as “artifact”. Since I grew up with a British English-derived variant of the English language, I spell it “artefact”. Just so you know. ↩
Support
If you liked this post, please buy me a coffee!