Building LaTeX documents on GitHub

17 minute read

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

TeXLive logo pointing to GitHub logo pointing to a PDF file icon
Building LaTeX documents via TeXLive on GitHub.
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! :tada:

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! :smile:

  1. 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… 

  2. I’m aware that the ubuntu-24.04 a.k.a ubuntu-latest Docker 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 with ubuntu-22.04, where I knew that everything worked. 

  3. Dorotea Avra is not the name of a real person. I generated it using a random name generation site

  4. 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!

buy me a coffee logo