Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

LaTeX & Typst (papers, theses, resumes and slides)  Module 12 – Automation, collaboration, conversion and publishing

Build automation, CI and reproducible PDFs

Build LaTeX and Typst PDFs with one command, on a clean CI machine on every push, and the same byte for byte twice, with latexmk, make, chktex and pinned tools.

  • Advanced
  • 25 minutes
  • Examples run with TeX Live 2026, TinyTeX 2026.10, Typst 0.15.1, Python 3.14.8, Pyodide 314.0.7, poppler 26.09.0, qpdf 12.4.1 and actionlint 1.7.12
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Configure latexmk and a Makefile so that one command builds the LaTeX and the Typst PDF of a project
  • Detect undefined references and citations in a build, and stop it instead of keeping a PDF that prints ??
  • Check LaTeX sources with chktex and fix what it reports
  • Write a CI workflow that installs pinned TeX Live and Typst versions, builds both PDFs and keeps them as artifacts
  • Reproduce a PDF byte for byte from its commit, and find the bytes that differ when two builds do not match

Before you start

What you’ll learn · Before you start

What you will learn

  • Configure latexmk and a Makefile so that one command builds the LaTeX and the Typst PDF of a project
  • Detect undefined references and citations in a build, and stop it instead of keeping a PDF that prints ??
  • Check LaTeX sources with chktex and fix what it reports
  • Write a CI workflow that installs pinned TeX Live and Typst versions, builds both PDFs and keeps them as artifacts
  • Reproduce a PDF byte for byte from its commit, and find the bytes that differ when two builds do not match

Before you start

On this page

A paper that builds on one laptop and nowhere else is a risk to everyone who needs its PDF. In this lesson’s session, the same commit built twice, a few seconds apart, gave two PDFs with different SHA-256 hashes, and a reference with a one-letter typo still produced a PDF, with ?? where a section number belonged.

Here a small project, a one-page field note kept as a LaTeX file and a Typst twin, gets a build that anyone runs with one command, that fails loudly on a missing reference or a typing slip, that runs again on a clean machine after every push, and that gives the same file byte for byte whenever it runs. The sessions below ran TeX Live 2026, installed as TinyTeX 2026.10, next to Typst 0.15.1.

One command builds both PDFs, with the options kept in the project

The project has five files: paper.tex, its Typst twin paper.typ, the shared refs.bib, and two files that say how to build them. latexmk: A program that runs LaTeX, BibTeX or Biber and other tools as many times as a document needs, until its references stop changing, and can clean up the helper files. More in “Your first document and the compile cycle” reads its settings from a file called latexmkrc or .latexmkrc in the folder it runs in, so the engine, its command-line flags and the file to build travel with the project. The Makefile then gives both systems one entry point:

The project's build files and its two sources

ci/latexmkrc

# latexmkrc: how this project is built. latexmk reads it from the project folder (as latexmkrc or .latexmkrc).
$pdf_mode = 1;                     # make a PDF with pdflatex
@default_files = ('paper.tex');    # "latexmk" on its own builds paper.tex
$pdflatex = 'pdflatex -interaction=nonstopmode -halt-on-error -file-line-error %O %S';
$warnings_as_errors = 1;           # an undefined reference or citation fails the build

ci/Makefile

# Makefile: the one way to build this project, on a laptop and in CI.
#   make         both PDFs: paper.pdf (LaTeX) and paper-typst.pdf (Typst)
#   make lint    chktex on the LaTeX source
#   make repro   two clean builds in separate folders, which must be the same byte for byte
#   make check   lint and repro, then the compared PDFs copied here: what the CI job runs
SOURCES = paper.tex paper.typ refs.bib latexmkrc

all: paper.pdf paper-typst.pdf

paper.pdf: paper.tex refs.bib latexmkrc
	@latexmk -silent

paper-typst.pdf: paper.typ refs.bib
	@typst compile --ignore-system-fonts paper.typ paper-typst.pdf

lint:
	@chktex -q -v0 paper.tex

repro:
	@rm -rf repro && mkdir -p repro/a repro/b
	@for d in a b; do cp $(SOURCES) Makefile repro/$$d/ && $(MAKE) -s -C repro/$$d all > repro/$$d.log 2>&1 || exit 1; done
	@cd repro && sha256sum a/paper.pdf b/paper.pdf a/paper-typst.pdf b/paper-typst.pdf
	@cmp repro/a/paper.pdf repro/b/paper.pdf && cmp repro/a/paper-typst.pdf repro/b/paper-typst.pdf
	@echo 'Both builds are the same, byte for byte.'

check: lint repro
	@cp repro/a/paper.pdf repro/a/paper-typst.pdf .

clean:
	@latexmk -C -silent
	@rm -rf repro paper-typst.pdf

.PHONY: all lint repro check clean
.DELETE_ON_ERROR:

ci/paper.tex

% paper.tex: a one-page field note, the LaTeX twin of paper.typ. latexmk builds it with the settings in latexmkrc.
\documentclass[a4paper]{article}
\usepackage{amsmath}
\usepackage{booktabs}
\usepackage[numbers]{natbib}
\usepackage{hyperref}
\hypersetup{pdftitle={Rain on two roofs}, pdfauthor={Meera Pillai}}
\title{Rain on two roofs}
\author{Meera Pillai}
\date{Field note 3}
\begin{document}
\maketitle
\section{Method}\label{sec:method}
Two gauges logged the rain on the roofs of Block~A and Block~B for a week \citep{rao2025gauges}.
The totals in Table~\ref{tab:totals} are the sums of the daily readings, and Section~\ref{sec:result} compares them.
\section{Result}\label{sec:result}
\begin{table}[h]
  \centering
  \caption{Weekly totals}\label{tab:totals}
  \begin{tabular}{lr}
    \toprule
    Roof & Rain (mm) \\
    \midrule
    Block A & 41.5 \\
    Block B & 38.0 \\
    \bottomrule
  \end{tabular}
\end{table}
The difference, $\Delta = 41.5 - 38.0 = 3.5$\,mm, is within the error of the gauges \citep{iyer2024calibration}.
\bibliographystyle{plainnat}
\bibliography{refs}
\end{document}

ci/paper.typ

// paper.typ: the Typst twin of paper.tex. The Makefile builds it with typst compile.
#set document(title: [Rain on two roofs], author: "Meera Pillai")
#set page(paper: "a4")
#set heading(numbering: "1")
#align(center)[
  #text(17pt)[Rain on two roofs] \
  Meera Pillai \
  Field note 3
]

= Method <sec:method>
Two gauges logged the rain on the roofs of Block A and Block B for a week @rao2025gauges.
The totals in @tab:totals are the sums of the daily readings, and @sec:result compares them.

= Result <sec:result>
#figure(
  table(
    columns: 2,
    align: (left, right),
    stroke: none,
    table.hline(),
    table.header[Roof][Rain (mm)],
    table.hline(stroke: 0.5pt),
    [Block A], [41.5],
    [Block B], [38.0],
    table.hline(),
  ),
  caption: [Weekly totals],
) <tab:totals>

The difference, $Delta = 41.5 - 38.0 = 3.5$ mm, is within the error of the gauges @iyer2024calibration.

#bibliography("refs.bib", style: "ieee")

$warnings_as_errors = 1 is the line the next section is about. This session runs make, lists the folder, and runs make again:

make builds both PDFs, then has nothing to do Python · build_session.py
from session import files, needs, run

# The project built the way its Makefile says: latexmk with the settings of latexmkrc, then typst compile. The second
# make finds nothing to do, because no source is newer than the PDFs it made.
needs("pdflatex", "latexmk", "typst", "make")
files("ci/paper.tex", "ci/paper.typ", "ci/refs.bib", "ci/latexmkrc", "ci/Makefile")
run("""
make
ls
make
""", env={"SOURCE_DATE_EPOCH": "1767225600"})

Output

$ make
Rc files read (in order):
  ./latexmkrc
Latexmk: Run number 1 of rule 'pdflatex'
This is pdfTeX, Version 3.141592653-2.6-1.40.29 (TeX Live 2026) (preloaded format=pdflatex)
 restricted \write18 enabled.
entering extended mode
Latexmk: Getting log file 'paper.log'
Latexmk: Missing bbl file 'paper.bbl' in following:
 No file paper.bbl.
Latexmk: Using bibtex to make bibliography file(s).
  ===Source file 'paper.bbl' for 'pdflatex'
Create bibtex paper
Latexmk: Run number 1 of rule 'bibtex paper'
Latexmk: Run number 2 of rule 'pdflatex'
This is pdfTeX, Version 3.141592653-2.6-1.40.29 (TeX Live 2026) (preloaded format=pdflatex)
 restricted \write18 enabled.
entering extended mode
Latexmk: Getting log file 'paper.log'
Latexmk: Using bibtex to make bibliography file(s).
  ===Source file 'paper.bbl' for 'pdflatex'
Latexmk: Run number 2 of rule 'bibtex paper'
Latexmk: Run number 3 of rule 'pdflatex'
This is pdfTeX, Version 3.141592653-2.6-1.40.29 (TeX Live 2026) (preloaded format=pdflatex)
 restricted \write18 enabled.
entering extended mode
Latexmk: Getting log file 'paper.log'
Latexmk: Using bibtex to make bibliography file(s).
  ===Source file 'paper.bbl' for 'pdflatex'

$ ls
latexmkrc
Makefile
paper-typst.pdf
paper.aux
paper.bbl
paper.blg
paper.fdb_latexmk
paper.fls
paper.log
paper.out
paper.pdf
paper.tex
paper.typ
refs.bib
$ make
make: Nothing to be done for `all'.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 build_session.py

latexmk began with Rc files read (in order): ./latexmkrc, the proof that the project’s settings were used. It then ran pdflatex three times and BibTeX twice, the number of runs this paper needs, and Typst built its twin in one run that printed nothing. Step through the same build:

Interactive · stage by stage What one make did to build both PDFs Step through the recorded build and watch which program runs, what it reads and why latexmk runs pdflatex again.
What one make did to build both PDFs, step 7 of 7. Showing make (target all: 2 PDFs), latexmk (pdf_mode 1, paper.tex), pdflatex (runs 2 and 3), bibtex paper (runs 1 and 2), typst compile (1 run, no output), Two PDFs. In focus: Two PDFs.run againmaketarget all: 2 PDFslatexmkpdf_mode 1, paper.texpdflatexruns 2 and 3bibtex paperruns 1 and 2typst compile1 run, no outputTwo PDFspaper.pdf, paper-typst.pdf

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. make reads the Makefile. make starts at the target all, which needs paper.pdf and paper-typst.pdf. Neither exists yet, so make runs the recipe of each, in order.
  2. latexmk reads latexmkrc. The recipe for paper.pdf is the single word latexmk. latexmk printed Rc files read: ./latexmkrc, so the engine, the three flags and the file to build come from the project, not from whoever types the command.
  3. The first pdflatex run. Run number 1 of rule pdflatex writes paper.aux with the labels and the two citation keys. There is no paper.bbl yet, so latexmk notes Missing bbl file and plans a BibTeX run.
  4. BibTeX writes the list. Run number 1 of rule bibtex paper reads the keys in paper.aux and the entries in refs.bib, and writes paper.bbl in the style of plainnat.bst.
  5. Run again until nothing changes. latexmk compares what each run wrote with what the next run read. pdflatex ran twice more and BibTeX once more, and after the third pdflatex run nothing had changed, so latexmk stopped.
  6. Typst needs one run. The second recipe, typst compile --ignore-system-fonts paper.typ paper-typst.pdf, resolves every reference and the bibliography in a single run, and printed nothing because nothing was wrong.
  7. Two PDFs, and nothing left to do. The folder now holds both PDFs and the files latexmk keeps between runs. A second make printed Nothing to be done for all, because no source was newer than its PDF.

The second make printed Nothing to be done for `all': make compares the times of files, and no source was newer than its PDF. That makes make cheap to run after every edit, and the same command serves a co-author, a teaching assistant and a CI job.

One file for the options

Put engine, flags and default files in latexmkrc, not in each person’s habits or editor settings. A co-author who types latexmk with no options then gets exactly the build that the CI job runs.

Checkpoint

Check: One command for both PDFs

  1. Question 1 of 2 In the recorded make, how many times did latexmk run pdflatex to build paper.pdf?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 3 runs

    latexmk logged Run number 1, 2 and 3 of rule pdflatex, with two BibTeX runs between them, and stopped when a run changed nothing that the next one would read. Typst built the twin in a single run.

  2. Question 2 of 2 A co-author clones the project and types latexmk with no options. What does it build, and with which flags?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: paper.tex with pdflatex, -interaction=nonstopmode, -halt-on-error and -file-line-error, read from latexmkrc

    Keeping the options in latexmkrc makes the plain command latexmk the project's build, for every person and every machine. latexmk printed Rc files read: ./latexmkrc to show it used the file.

A missing reference should fail the build

A reference to a label that does not exist is not an error in LaTeX. pdfLaTeX prints ?? in the PDF, writes a warning to the log and ends with exit status 0, which a script reads as success. This session gives the paper a one-letter typo, \ref{sec:results} for the label sec:result, and builds it without the project’s settings (-norc, in a folder of its own), then with them, then with -Werror alone in a third folder, and then gives the Typst twin the same typo:

One typo, built four ways Python · warnings_session.py
from session import files, needs, run

# One reference with a typo, \ref{sec:results} for the label sec:result. latexmk builds it without the project's
# latexmkrc (-norc, in a folder of its own), then with it, then without it but with -Werror (in a third folder), and
# ls shows that each of the three builds wrote its PDF; then the Typst twin gets the same typo.
needs("pdflatex", "latexmk", "typst", "pdftotext")
files("ci/paper.tex", "ci/paper.typ", "ci/refs.bib", "ci/latexmkrc")
run("""
sed 's/ref{sec:result}/ref{sec:results}/' paper.tex > typo.tex
mkdir plain && cp typo.tex refs.bib plain/
cd plain && latexmk -norc -pdf -silent typo.tex > /dev/null 2>&1 && echo 'latexmk ended with exit status 0'
pdftotext plain/typo.pdf - | grep 'Section'
!latexmk -silent typo.tex > typo-build.txt 2>&1
grep -A1 -E 'Problematic|treated as error' typo-build.txt
mkdir werror && cp typo.tex refs.bib werror/
!cd werror && latexmk -norc -pdf -Werror -silent typo.tex > /dev/null 2>&1
ls plain/typo.pdf typo.pdf werror/typo.pdf
sed 's/@sec:result compares/@sec:results compares/' paper.typ > typo.typ
!typst compile typo.typ
""", env={"SOURCE_DATE_EPOCH": "1767225600"})

Output

$ sed 's/ref{sec:result}/ref{sec:results}/' paper.tex > typo.tex
$ mkdir plain && cp typo.tex refs.bib plain/
$ cd plain && latexmk -norc -pdf -silent typo.tex > /dev/null 2>&1 && echo 'latexmk ended with exit status 0'
latexmk ended with exit status 0
$ pdftotext plain/typo.pdf - | grep 'Section'
[2]. The totals in Table 1 are the sums of the daily readings, and Section ??
$ latexmk -silent typo.tex > typo-build.txt 2>&1
[exit status 12]
$ grep -A1 -E 'Problematic|treated as error' typo-build.txt
Latexmk: ====Problematic refs and citations with line #s in .tex file:
  Reference `sec:results' on page 1 undefined on input line 15
--
   Warning(s) from latex (or c.) for 'typo.tex'; treated as error
----------------------
$ mkdir werror && cp typo.tex refs.bib werror/
$ cd werror && latexmk -norc -pdf -Werror -silent typo.tex > /dev/null 2>&1
[exit status 12]
$ ls plain/typo.pdf typo.pdf werror/typo.pdf
plain/typo.pdf
typo.pdf
werror/typo.pdf
$ sed 's/@sec:result compares/@sec:results compares/' paper.typ > typo.typ
$ typst compile typo.typ
error: label `<sec:results>` does not exist in the document
   ┌─ typo.typ:13:66
   │
13 │ The totals in @tab:totals are the sums of the daily readings, and @sec:results compares them.
   │                                                                   ^^^^^^^^^^^^

[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 warnings_session.py

Without the settings, latexmk ended with exit status 0, and the PDF said Section ??. With $warnings_as_errors = 1 in latexmkrc, the same build listed Reference `sec:results' on page 1 undefined on input line 15, said treated as error and ended with exit status 12. latexmk’s help lists the same switch as the option -Werror, “treat warnings from called programs as errors”; given on the command line, without the settings file, it ended the build in a third folder with exit status 12 too. Typst needs no setting: a reference to a missing label is an error there, reported with its file, line and column, and the run ends with exit status 1.

ls then found a PDF from each of the three LaTeX builds, ?? and all: only the exit status differed. That status is what a Makefile and a CI job act on, so it is the part to get right. The Makefile also names .DELETE_ON_ERROR, which tells make to delete a target whose recipe failed, so that a PDF with ?? never sits in the folder looking finished.

A build that passes with ?? in it

Wrong: a CI job that runs latexmk -pdf paper.tex and keeps whatever PDF it writes. Right: $warnings_as_errors = 1 in latexmkrc, or latexmk -Werror, so that an undefined reference or citation ends the job with a non-zero exit status, and no PDF is kept.

Checkpoint

Check: Warnings that fail the build

  1. Question 1 of 2 Without the project's settings, latexmk built typo.tex, whose \ref names a label that does not exist. What happened?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It ended with exit status 0, and the PDF printed Section ??

    A CI job reads only the exit status, so a build that ends with 0 and a PDF full of ?? passes unless warnings are made errors.

  2. Question 2 of 2 What did typst compile do with @sec:results, a reference to a label that does not exist?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It stopped with error: label <sec:results> does not exist in the document, and exit status 1

    Typst needs no setting to fail on a broken reference; LaTeX needs latexmk's warnings-as-errors.

chktex finds typing slips before anyone reads the PDF

Some mistakes compile without a warning: a hyphen where a range needs an en dash, a full stop after “e.g.” that LaTeX takes for the end of a sentence, a straight " that prints the wrong quotation mark. A A program that reads source files without building them and reports likely mistakes in them, such as chktex for LaTeX; a formatter goes further and rewrites the layout of the source. catches them in the source. chktex, whose CTAN entry says it reports typographic and other errors in LaTeX documents, read this draft paragraph and the finished paper:

draft.tex: five slips in two lines TEX · lint/draft.tex
% draft.tex: a paragraph with five slips that chktex reports.
\documentclass{article}
\begin{document}
\section{Method}\label{sec:method}
Two gauges, e.g. the ones on Block A, logged the rain for days 1-7.
The totals are in Table \ref{tab:totals}, and the gauges are "low-cost" ones...
\end{document}
chktex on the draft and on the paper Python · lint_session.py
from session import files, needs, run

# chktex on a draft paragraph with five common slips, and on the finished paper.tex.
needs("chktex")
files("lint/draft.tex", "ci/paper.tex")
run("""
!chktex -q -v0 draft.tex
chktex -q -v0 paper.tex
""")

Output

$ chktex -q -v0 draft.tex
draft.tex:5:17:12:Interword spacing (`\ ') should perhaps be used.
draft.tex:5:65:8:Wrong length of dash may have been used.
draft.tex:6:24:2:Non-breaking space (`~') should have been used.
draft.tex:6:62:18:Use either `` or '' as an alternative to `"'.
draft.tex:6:71:18:Use either `` or '' as an alternative to `"'.
draft.tex:6:77:11:You should use \ldots to achieve an ellipsis.
[exit status 2]
$ chktex -q -v0 paper.tex

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lint_session.py

With -v0 each finding is one line: file, line, column, warning number and message. The draft got six, because the two straight quotation marks count once each, and chktex ended with exit status 2; the paper got none and exit status 0. So make lint fails a CI job on any finding. Each warning names its fix:

Warning What chktex saw The fix
12 e.g. the: a full stop that LaTeX takes as a sentence end e.g.\ the
8 1-7: a hyphen in a range 1--7, an en dash
2 Table \ref{…}: a space where a line may break Table~\ref{…}
18 "low-cost": straight quotation marks ``low-cost''
11 ...: three full stops \ldots

A warning that is wrong for one line, such as a hyphen in a phone number, is muted for that line with a comment, % chktex 8, rather than for the whole project. Two formatters go further and rewrite a file’s layout: latexindent for LaTeX and typstyle for Typst, whose --check mode exits with a non-zero status when a file is not formatted. Run them in CI only after the whole group has agreed to use them.

Key idea

A build is a check only if every problem it finds ends it with a non-zero exit status: latexmk with warnings treated as errors, Typst with its errors, chktex with its findings.

Checkpoint

Check: Linting with chktex

  1. One question make lint runs chktex -q -v0 paper.tex. Why does that fail a CI job when chktex finds something?

    Choose one answer.

    Show the answer: One question

    Answer: chktex ended with exit status 2 when it reported warnings, and make stops at a recipe that fails

    Every check in a CI job works through its exit status. chktex's is 2 when it has printed warnings, so make lint fails a job until the slips are fixed or muted.

The CI job runs the same command on a clean machine, with pinned tools

continuous integration (CI): Building and checking a project automatically on a server every time a change is pushed, so that a change that breaks the build is found at once rather than before a deadline. runs the build on a server after every push. On GitHub, a workflow file in .github/workflows/ says when to run and what to do. This one installs both systems at exact versions, runs make check and keeps the PDFs:

pdf.yml: save as .github/workflows/pdf.yml YAML · ci/pdf.yml
# Save as .github/workflows/pdf.yml. On every push to main and every pull request: install TinyTeX 2026.10 and
# Typst 0.15.1 at fixed versions, lint, build both PDFs twice and compare them, and keep the PDFs for 30 days.
name: PDFs

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    env:
      TINYTEX: '2026.10'
      TYPST: '0.15.1'
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Install TinyTeX 2026.10 (TeX Live 2026)
        run: |
          archive="TinyTeX-1-linux-x86_64-v${TINYTEX}.tar.xz"
          curl -fsSLO "https://github.com/rstudio/tinytex-releases/releases/download/v${TINYTEX}/${archive}"
          echo "4d519d6236ee6798e3ec0d8e2093d1fda01aeb267aa22c2a5586d76bcfce6566  ${archive}" | sha256sum --check
          tar -xJf "${archive}" -C "$HOME"
          echo "$HOME/.TinyTeX/bin/x86_64-linux" >> "$GITHUB_PATH"

      - name: Add chktex and record every TeX package version
        run: |
          tlmgr install chktex
          tlmgr info --only-installed --data name,localrev > tex-packages.txt

      - name: Install Typst 0.15.1
        run: |
          archive="typst-x86_64-unknown-linux-musl.tar.xz"
          curl -fsSLO "https://github.com/typst/typst/releases/download/v${TYPST}/${archive}"
          echo "a6d077d0a95eed5a2eba715b2dae06be954f624ccbf85758a03f389ded33118c  ${archive}" | sha256sum --check
          tar -xJf "${archive}"
          echo "$PWD/typst-x86_64-unknown-linux-musl" >> "$GITHUB_PATH"

      - name: Lint, build twice and compare
        run: |
          SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)"
          export SOURCE_DATE_EPOCH
          make check

      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: pdfs
          path: |
            paper.pdf
            paper-typst.pdf
            tex-packages.txt
          if-no-files-found: error
          retention-days: 30

Every version in it is pin: To fix a tool, a package or an action at one exact version, or a download at one exact file by its hash, so that every build uses it until the pin is changed on purpose.. The runner is ubuntu-24.04, not ubuntu-latest, which GitHub moves to newer images over time. Each action is named by a full commit SHA, which cannot move to other code later. TeX Live comes from the TinyTeX 2026.10 release as TinyTeX-1, a bundle of the TeX Live 2026 tree, and Typst from its 0.15.1 release, each checked with sha256sum --check against the SHA-256 value written in the workflow, so a changed download stops the job. The SHA-256 values are those that GitHub’s release pages list for the two archives. Step through one run:

Interactive · message by message One push, one run of the PDF workflow Step through what happens after a push, from the clean machine to the two PDFs you download, and where a failure stops it.
One push, one run of the PDF workflow, step 9 of 9. Messages so far: You to The runner: push to main; The runner to Artifacts: 2 PDFs, package list; Artifacts to You: the artifact.Youpush a commitThe runnerfailed: nothing uploadedArtifactskept for 30 dayspush to main2 PDFs, package listthe artifactno artifact

The drawing shows the whole scene; its 9 steps are listed below.

Every step as text
  1. A push starts a run. A push to main matches the workflow's on: push trigger. A fresh virtual machine starts with none of your files and no TeX installation.
  2. Check out, read-only. actions/checkout, pinned to a full commit SHA, copies the commit that started the run. The token can only read the repository, and persist-credentials false keeps it out of the Git settings the build sees.
  3. TinyTeX at one version. The job downloads TinyTeX-1 2026.10, which is TeX Live 2026, and sha256sum --check compares the archive with the hash written in the workflow. Any other file stops the job here.
  4. Record what tlmgr added. tlmgr installs chktex, which TinyTeX-1 lacks, from the repository as it is that day. tlmgr info then writes every installed package and its revision to tex-packages.txt.
  5. Typst at one version. The Typst 0.15.1 archive is checked against its own SHA-256 value the same way, unpacked and put on the PATH for the steps that follow.
  6. make check at commit time. SOURCE_DATE_EPOCH is set to the time of the commit. make check runs chktex, builds both PDFs twice, in two folders, compares them byte for byte, and copies the pair it compared into the project folder.
  7. Keep the PDFs. upload-artifact stores paper.pdf, paper-typst.pdf and tex-packages.txt for 30 days. With if-no-files-found set to error, the job fails when none of the listed files is there.
  8. Download from the run. You download the artifact from the run's page. The PDF in it was built from that commit, on a clean runner, by the pinned tools, and its hash is in the log.
  9. When a check fails. If make check ends with a non-zero status, as latexmk does with exit status 12 for an undefined reference, the job is marked failed and the upload step is skipped, so no PDF with ?? is kept.

One step cannot be pinned this way. tlmgr installs chktex, which TinyTeX-1 does not include, from the TeX Live repository as it is that day, and packages there are updated as their authors release them. The job therefore writes every installed package and its revision to tex-packages.txt and keeps that file with the PDFs, so two builds can be compared later. Once TeX Live 2027 is out, the 2026 repository moves to TUG’s historic archive, frozen in a tlnet-final folder; point tlmgr there to rebuild an old paper with its own year’s packages.

The upload step needs no condition. A step runs only when every step before it succeeded, the default success() check, so a failed make check leaves nothing to download. GitHub keeps build artifact: A file that a CI job keeps after it ends, such as a built PDF, for people to download from the run or for later jobs to use; it expires after a set number of days. for 90 days unless a repository changes it; retention-days: 30 keeps these for 30. actionlint, a checker for workflow files that also runs ShellCheck on each run: script, read a draft and the final file, and the session then ran the workflow’s own tlmgr command on this lesson’s TinyTeX:

actionlint on a draft and on pdf.yml, then the package list Python · actionlint_session.py
from session import files, needs, read, run, write

# actionlint, with ShellCheck for the scripts of the run: steps, on a draft of the workflow whose GITHUB_PATH line is
# not quoted, then on the finished pdf.yml. Last, the tlmgr command with which pdf.yml records the TeX packages, run on
# this lesson's TinyTeX for two of them: first with a field name tlmgr does not know, which actionlint cannot see, then
# with the field pdf.yml uses.
needs("actionlint", "shellcheck", "pdflatex")
files("ci/pdf.yml")
write("draft.yml", read("pdf.yml").replace('echo "$HOME/.TinyTeX/bin/x86_64-linux" >> "$GITHUB_PATH"',
                                           'echo $HOME/.TinyTeX/bin/x86_64-linux >> $GITHUB_PATH'))
run("""
grep -n 'TinyTeX/bin' draft.yml
!actionlint draft.yml
actionlint pdf.yml
!tlmgr info --only-installed --data name,revision chktex latexmk
tlmgr info --only-installed --data name,localrev chktex latexmk
""")

Output

$ grep -n 'TinyTeX/bin' draft.yml
31:          echo $HOME/.TinyTeX/bin/x86_64-linux >> $GITHUB_PATH
$ actionlint draft.yml
draft.yml:26:9: shellcheck reported issue in this script: SC2086:info:5:41: Double quote to prevent globbing and word splitting [shellcheck]
   |
26 |         run: |
   |         ^~~~
draft.yml:26:9: shellcheck reported issue in this script: SC2086:info:5:6: Double quote to prevent globbing and word splitting [shellcheck]
   |
26 |         run: |
   |         ^~~~
[exit status 1]
$ actionlint pdf.yml
$ tlmgr info --only-installed --data name,revision chktex latexmk
tlmgr info: unknown data field: revision
tlmgr: An error has occurred. See above messages. Exiting.
[exit status 2]
$ tlmgr info --only-installed --data name,localrev chktex latexmk
chktex,78219
latexmk,78335

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 actionlint_session.py

The draft wrote echo $HOME/.TinyTeX/bin/x86_64-linux >> $GITHUB_PATH without quotes, and ShellCheck’s SC2086 flagged both variables; pdf.yml quotes them and passed. A linter reads the workflow, not what each command accepts: asked for a field called revision, tlmgr stopped with unknown data field and exit status 2, which would end the job, and only running the command found it. With localrev, the field pdf.yml uses, it printed one line per package, its name and the revision installed, chktex,78219 among them: the format of tex-packages.txt. So run every command of a workflow once on your own machine. MySmartCoPilot’s GitHub Actions Linter runs similar static checks on a workflow you paste into it.

GitHub Actions Linter (Workflow Security and Expression Checks) Paste pdf.yml to check its expressions, jobs and steps in your browser.

Projects and companies named here

MySmartCoPilot has no tie to GitHub, the TeX Users Group, the TinyTeX project or Typst. What this lesson says about their services and releases is taken from their own documentation, linked in the references.

Checkpoint

Check: A CI job with pinned tools

  1. Question 1 of 2 Why does pdf.yml check the TinyTeX and Typst archives with sha256sum --check before unpacking them?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: So that the job stops unless it got exactly the archive whose SHA-256 value is written in the workflow

    A pinned version is a promise about which program runs. The SHA-256 check turns that promise into a test the job performs every time.

  2. Question 2 of 2 A commit adds a reference with a typo. make check fails in the job. What happens to the upload-artifact step?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It is skipped, because a step runs only when every earlier step succeeded, so no PDF is kept

    The order of the steps is the policy: checks first, upload last, and the default success() check keeps a failed build's files out of the artifacts.

Two builds of one commit differ until both use the commit’s time

A PDF records when it was made. pdfLaTeX writes the time of the run into the document information, as /CreationDate and /ModDate, and its manual says the file’s /ID is made from the input file name and the starting time. So two builds of the same sources, a second apart, give different files. make repro builds the project twice, in repro/a and repro/b, and compares the PDFs; pdfcompare.py says which fields differ and whether any other byte does:

pdfcompare.py: dates, IDs and every other byte Python · pdfcompare.py
"""Compares two PDFs field by field: the build dates, the file ID, and every other byte.

    python3 pdfcompare.py a.pdf b.pdf

qpdf first rewrites both files in its QDF form (every object written out plainly, no compressed streams), so that
the dates and IDs can be found wherever the engine put them: the document information dictionary (/CreationDate,
/ModDate), the trailer's /ID and the XMP metadata (xmp:CreateDate, xmp:ModifyDate, xmpMM:InstanceID). Each of those
fields is reported as the same or different; then all of them are blanked out and the rest of the two files is
compared byte for byte. The values themselves are not printed, because they change with every build.
"""

import re
import subprocess
import sys
import tempfile
from pathlib import Path

FIELDS = {
    "CreationDate": rb"/CreationDate\s*\(([^)]*)\)",
    "ModDate": rb"/ModDate\s*\(([^)]*)\)",
    "ID": rb"/ID\s*\[\s*(<[0-9A-Fa-f]*>\s*<[0-9A-Fa-f]*>)\s*\]",
    "xmp:CreateDate": rb"<xmp:CreateDate>([^<]*)<",
    "xmp:ModifyDate": rb"<xmp:ModifyDate>([^<]*)<",
    "xmpMM:InstanceID": rb"<xmpMM:InstanceID>([^<]*)<",
}


def qdf(pdf, folder):
    """The bytes of `pdf` rewritten by qpdf: plain objects and streams, the same layout for the same content."""
    out = Path(folder) / (Path(pdf).name + ".qdf")
    subprocess.run(["qpdf", "--qdf", "--object-streams=disable", "--compress-streams=n", "--decode-level=generalized",
                    pdf, str(out)], check=True)
    return out.read_bytes()


def blank(data):
    """The bytes with every field's value replaced by the same placeholder."""
    for pattern in FIELDS.values():
        data = re.sub(pattern, lambda m: m.group(0).replace(m.group(1), b"-"), data)
    return data


first, second = sys.argv[1], sys.argv[2]
with tempfile.TemporaryDirectory() as folder:
    a = qdf(first, folder)
    b = qdf(second, folder)
print(f"{'field':<18} {first} and {second}")
for name, pattern in FIELDS.items():
    found_a, found_b = re.findall(pattern, a), re.findall(pattern, b)
    if not found_a and not found_b:
        continue
    print(f"{name:<18} {'same' if found_a == found_b else 'different'}")
print(f"{'every other byte':<18} {'same' if blank(a) == blank(b) else 'different'}")
make repro without SOURCE_DATE_EPOCH, make check with it, then LuaLaTeX Python · repro_session.py
from session import files, needs, run

# make repro builds the project twice, in repro/a and repro/b, and compares the PDFs byte for byte, first without
# SOURCE_DATE_EPOCH. The hashes of that pair change on every run, so the page shows them as [a hash of this build].
# Then make check, the CI job's command, runs chktex and make repro with the time of the last commit, as the CI job
# sets it, and copies the two PDFs it compared into the project folder, where the upload step looks for them. Last,
# LuaLaTeX builds the paper in two folders.
needs("pdflatex", "lualatex", "latexmk", "typst", "make", "git", "qpdf", "chktex")
files("ci/paper.tex", "ci/paper.typ", "ci/refs.bib", "ci/latexmkrc", "ci/Makefile", "pdfcompare.py")
run("""
git init -q -b main .
git add paper.tex paper.typ refs.bib latexmkrc Makefile
git commit -q -m 'Field note 3'
git log -1 --format=%ct
!make repro
python3 pdfcompare.py repro/a/paper.pdf repro/b/paper.pdf
python3 pdfcompare.py repro/a/paper-typst.pdf repro/b/paper-typst.pdf
""", mask=[(r"\b[0-9a-f]{64}\b", "[a hash of this build]"), (r"differ: char \d+, line \d+", "differ: char …, line …")],
    env={"SOURCE_DATE_EPOCH": None})
run("""
SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" make check
ls *.pdf
""")
run("""
mkdir -p lua/a lua/b && cp paper.tex refs.bib lua/a/ && cp paper.tex refs.bib lua/b/
cd lua/a && latexmk -norc -lualatex -silent paper.tex > /dev/null 2>&1
cd lua/b && latexmk -norc -lualatex -silent paper.tex > /dev/null 2>&1
python3 pdfcompare.py lua/a/paper.pdf lua/b/paper.pdf
""", env={"SOURCE_DATE_EPOCH": "1767240000"})

Output

$ git init -q -b main .
$ git add paper.tex paper.typ refs.bib latexmkrc Makefile
$ git commit -q -m 'Field note 3'
$ git log -1 --format=%ct
1767240000
$ make repro
[a hash of this build]  a/paper.pdf
[a hash of this build]  b/paper.pdf
[a hash of this build]  a/paper-typst.pdf
[a hash of this build]  b/paper-typst.pdf
repro/a/paper.pdf repro/b/paper.pdf differ: char …, line …
make: *** [repro] Error 1
[exit status 2]
$ python3 pdfcompare.py repro/a/paper.pdf repro/b/paper.pdf
field              repro/a/paper.pdf and repro/b/paper.pdf
CreationDate       different
ModDate            different
ID                 different
every other byte   same
$ python3 pdfcompare.py repro/a/paper-typst.pdf repro/b/paper-typst.pdf
field              repro/a/paper-typst.pdf and repro/b/paper-typst.pdf
CreationDate       different
ModDate            different
ID                 different
xmp:CreateDate     different
xmp:ModifyDate     different
xmpMM:InstanceID   different
every other byte   same
$ SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" make check
535dd7469b5a03dbc23836bb00efd7ac023c7055722846a939a5175a78e37572  a/paper.pdf
535dd7469b5a03dbc23836bb00efd7ac023c7055722846a939a5175a78e37572  b/paper.pdf
690d25acaa869a46f615267673553ae715441cd6aa5a6a2b7ec7c444963f019b  a/paper-typst.pdf
690d25acaa869a46f615267673553ae715441cd6aa5a6a2b7ec7c444963f019b  b/paper-typst.pdf
Both builds are the same, byte for byte.
$ ls *.pdf
paper-typst.pdf
paper.pdf
$ mkdir -p lua/a lua/b && cp paper.tex refs.bib lua/a/ && cp paper.tex refs.bib lua/b/
$ cd lua/a && latexmk -norc -lualatex -silent paper.tex > /dev/null 2>&1
$ cd lua/b && latexmk -norc -lualatex -silent paper.tex > /dev/null 2>&1
$ python3 pdfcompare.py lua/a/paper.pdf lua/b/paper.pdf
field              lua/a/paper.pdf and lua/b/paper.pdf
CreationDate       same
ModDate            same
ID                 different
every other byte   same

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 repro_session.py

Without a fixed time, make repro failed: the pdfLaTeX PDFs differed in CreationDate, ModDate and ID, the Typst PDFs also in the dates and instance ID of their XMP metadata, and every other byte was the same. The cure is a standard. The SOURCE_DATE_EPOCH: An environment variable that holds a time as seconds since the Unix epoch. Build tools that support it, such as pdfTeX and Typst, write that time into their output instead of the time of the build. specification says it holds a time in seconds, like the output of date +%s, and that build processes must use it for embedded timestamps instead of the current time. pdfTeX and Typst both read it; Typst’s --creation-timestamp option sets the same value. The session then ran make check, the CI job’s command, with the time of the last commit, git log -1 --format=%ct, 1767240000 here: chktex found nothing, both pairs were identical, with the same hashes in repro/a and repro/b, and the pair it compared was copied into the project folder, where ls found the two PDFs that the upload step keeps. The commit’s time is the right value, because it changes only when the sources change.

LuaLaTeX needs one more condition. Built in two folders with the same time, its PDFs differed only in ID, so LuaTeX’s file ID depends on the folder as well, and make repro, which builds in two folders, would fail for a LuaLaTeX paper. GitHub’s documentation of its runners adds that file paths on their virtual machines are not static, so two CI runs, or a CI run and your laptop, cannot be counted on to build in the same folder. Compare LuaLaTeX builds without the ID, as pdfcompare.py does, or build both in one fixed folder.

\today still prints the day of the build

SOURCE_DATE_EPOCH fixes the dates in the PDF’s metadata, not the date that \today prints. pdfTeX’s manual adds FORCE_SOURCE_DATE=1, which makes \today follow it too, and advises against setting it except in unusual cases. Write the date into the source, \date{Field note 3} or a real date, and a rebuild a year later still says the same.

Checkpoint

Check: Byte-identical builds

  1. Question 1 of 2 Without SOURCE_DATE_EPOCH, which fields differed between the two pdfLaTeX builds of make repro?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • ModDate
    • CreationDate
    • ID

    Only the fields that hold the time of the build changed. With the commit's time in SOURCE_DATE_EPOCH, they matched too, and both builds had the same SHA-256 hash.

  2. Question 2 of 2 With SOURCE_DATE_EPOCH set, LuaLaTeX built paper.tex in lua/a and in lua/b. What did pdfcompare.py report?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The dates the same, the ID different, every other byte the same

    Compare LuaLaTeX builds without the ID, or build them in one fixed folder; GitHub says the file paths on its runners are not static, so a CI run cannot promise the folder of an earlier one.

Practise: check a build log and compare two builds

Two small checks make a CI job strict. The first reads a pdflatex log the way latexmk’s warnings-as-errors does; the second reads the output of make repro. Each runs here against sample tests made from this lesson’s sessions, and its first hint is open:

Code lab · 2 steps

Two checks for a strict build

Find every undefined reference and citation in a pdflatex log, then name the files that two builds did not make the same.

  1. Find the undefined references and citations in a log To do
  2. Say which files two builds made differently To do

Exercise · Medium · Python

Find the undefined references and citations in a log

A CI job should fail when a PDF would print ??. Write undefined(log) in logcheck.py. Given the text of a pdflatex .log file, it returns every undefined reference and citation, in the order the log reports them, as tuples (kind, key, line):

  • a warning such as `LaTeX Warning: Reference sec:results' on page 1 undefined on input line 15.` gives ("reference", "sec:results", 15)`;
  • a warning about a citation, from LaTeX itself or from a package such as natbib (`Package natbib Warning: Citation rao2025gauge' on page 1 undefined on input line 14.`), gives ("citation", "rao2025gauge", 14)`;
  • the summary lines at the end of a run, such as LaTeX Warning: There were undefined references., give nothing;
  • TeX breaks log lines after 79 characters: a line of exactly 79 characters continues on the next line, so join the two before you read the warning.

A log with no such warning gives an empty list.

Starter code · logcheck.py

def undefined(log):
    """Return [(kind, key, line)] for every undefined reference and citation the log reports, in order."""
    # Replace this line: join the lines TeX broke at 79 characters, then read each warning.
    return []
The sample tests · test_logcheck.py
from logcheck import undefined

# Lines of a pdflatex log in the form the lesson's typo.tex gave, with a misspelt citation key added
# (rao2025gauge for rao2025gauges) and the lines around them.
TYPO_LOG = (
    "(./typo.bbl)\n"
    "Package natbib Warning: Citation `rao2025gauge' on page 1 undefined on input line 14.\n"
    "\n"
    "\n"
    "LaTeX Warning: Reference `sec:results' on page 1 undefined on input line 15.\n"
    "\n"
    "[1{/usr/local/texlive/2026/texmf-var/fonts/map/pdftex/updmap/pdftex.map}]\n"
    "Package natbib Warning: There were undefined citations.\n"
    "\n"
    "LaTeX Warning: There were undefined references.\n"
)


def test_reference_and_citation():
    """finds the citation and the reference, in the order of the log"""
    assert undefined(TYPO_LOG) == [("citation", "rao2025gauge", 14), ("reference", "sec:results", 15)]


def test_summary_lines_only():
    """gives nothing for the summary lines alone"""
    log = "Package natbib Warning: There were undefined citations.\nLaTeX Warning: There were undefined references.\n"
    assert undefined(log) == []


def test_clean_log():
    """gives an empty list for a log without warnings"""
    assert undefined("(./paper.aux)\nOutput written on paper.pdf (1 page, 84725 bytes).\n") == []


def test_latex_citation_warning():
    """reads a citation warning from LaTeX itself"""
    log = "LaTeX Warning: Citation `iyer2024' on page 2 undefined on input line 31.\n"
    assert undefined(log) == [("citation", "iyer2024", 31)]


def test_every_occurrence():
    """reports a key once for every line that uses it"""
    log = (
        "LaTeX Warning: Reference `tab:totals' on page 1 undefined on input line 15.\n"
        "LaTeX Warning: Reference `tab:totals' on page 2 undefined on input line 40.\n"
    )
    assert undefined(log) == [("reference", "tab:totals", 15), ("reference", "tab:totals", 40)]


def test_broken_line():
    """joins a warning that TeX broke after 79 characters"""
    first = "LaTeX Warning: Reference `fig:roof-gauges-block-a-and-block-b' on page 3 undefined "
    first, rest = first[:79], first[79:] + "on input line 52.\n"
    assert len(first) == 79
    assert undefined(first + "\n" + rest) == [("reference", "fig:roof-gauges-block-a-and-block-b", 52)]
A hint

First undo TeX's line breaks: walk through log.split("\n") and, while a line is exactly 79 characters long, add the next line to it. Then one regular expression finds both kinds of warning: Warning: (Reference|Citation) (.+?)' on page \d+ undefined on input line (\d+)\. Turn the first group into lower case and the last into an int`.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Easy · Python

Say which files two builds made differently

make repro builds the project in two folders, a/ and b/, and prints what sha256sum says about each PDF: one line per file, its SHA-256 hash in 64 hexadecimal digits, two spaces, and its path. Write differing(output) in hashes.py. Given that text, it returns the sorted list of file names (the part after a/ or b/) that the two builds did not make identically:

  • a file whose two hashes differ is listed;
  • a file that only one of the folders has is listed too, because the other build did not make it;
  • a file with the same hash in both folders is not listed;
  • blank lines are ignored.

An empty list means the two builds are the same, byte for byte.

Starter code · hashes.py

def differing(output):
    """Return the sorted names of the files that builds a/ and b/ did not make identically."""
    # Replace this line: read each line's hash and path, then compare the two folders file by file.
    return []
The sample tests · test_hashes.py
from hashes import differing

A = "535dd7469b5a03dbc23836bb00efd7ac023c7055722846a939a5175a78e37572"
B = "690d25acaa869a46f615267673553ae715441cd6aa5a6a2b7ec7c444963f019b"
C = "9b41adaf336625d0002c25e602d873d5ac228183863129181a9802457b3a126c"
D = "3c4359e3344bd619cf3c49160620bac33c9962e06f4b8a61b33ddf7750155a9f"


def test_same_builds():
    """returns an empty list when every file has the same hash in both folders"""
    output = f"{A}  a/paper.pdf\n{A}  b/paper.pdf\n{B}  a/paper-typst.pdf\n{B}  b/paper-typst.pdf\n"
    assert differing(output) == []


def test_one_file_differs():
    """lists the file whose two hashes differ"""
    output = f"{C}  a/paper.pdf\n{D}  b/paper.pdf\n{B}  a/paper-typst.pdf\n{B}  b/paper-typst.pdf\n"
    assert differing(output) == ["paper.pdf"]


def test_sorted_names():
    """lists every differing file, sorted by name"""
    output = f"{C}  a/paper.pdf\n{D}  b/paper.pdf\n{A}  a/paper-typst.pdf\n{B}  b/paper-typst.pdf\n"
    assert differing(output) == ["paper-typst.pdf", "paper.pdf"]


def test_missing_in_one_folder():
    """lists a file that only one build made"""
    output = f"{A}  a/paper.pdf\n{A}  b/paper.pdf\n{B}  a/slides.pdf\n"
    assert differing(output) == ["slides.pdf"]


def test_blank_lines():
    """ignores blank lines"""
    output = f"\n{A}  a/paper.pdf\n\n{A}  b/paper.pdf\n\n"
    assert differing(output) == []


def test_name_with_folder():
    """keeps the folders below a/ and b/ in the name"""
    output = f"{C}  a/figures/rain.pdf\n{D}  b/figures/rain.pdf\n"
    assert differing(output) == ["figures/rain.pdf"]
A hint

Split each line once on whitespace: digest, path = line.split(maxsplit=1). Then split the path on its first / to get the folder and the file name, and keep two dictionaries, one for a and one for b, from file names to hashes.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Hash Generator Hash two downloaded PDFs in your browser and compare the SHA-256 values before you call them the same file.

Then work through the self-check below. It opens with the idea of this lesson you have shown least so far, and the feedback on every choice names what the choice gets right or leaves out:

Practice that adapts to you

Practise this lesson

15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 15 Which file names does latexmk read its project settings from, in the folder it runs in?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: latexmkrc or .latexmkrc

    A latexmkrc in the project folder is how a project carries its own build options.

  2. Question 2 of 15 Why did the second make print Nothing to be done for all?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: Both PDFs existed and were newer than every source they depend on

    A target is rebuilt only when it is missing or older than something it depends on, which is why make is cheap to run after every edit.

  3. Question 3 of 15 The Makefile names .DELETE_ON_ERROR. What goes wrong without it after latexmk fails on an undefined reference?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: paper.pdf, written with ?? in it, stays newer than the sources, so the next make says there is nothing to do

    A failed build must not leave a file that looks finished. .DELETE_ON_ERROR makes make remove the target when its recipe fails, so the next make tries again.

  4. Question 4 of 15 Which latexmkrc line made latexmk end with exit status 12 for the undefined reference?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: $warnings_as_errors = 1;

    -halt-on-error deals with errors; $warnings_as_errors, or -Werror, deals with the warnings that leave ?? in a PDF.

  5. Question 5 of 15 A co-author cites rao2025gauge instead of rao2025gauges. With the project's latexmkrc, what does the build do?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: It fails, because natbib's warning about an undefined citation is a warning like any other

    Misspelt keys are the commonest undefined citations; the same setting that catches ?? catches them.

  6. Question 6 of 15 chktex reported Warning 8, Wrong length of dash, for days 1-7. What is the fix?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: days 1--7, an en dash for the range

    Each chktex warning number names one kind of slip, and the fix is usually one character.

  7. Question 7 of 15 Why did chktex ask for Table~\ref{tab:totals} instead of Table \ref{tab:totals}?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: A normal space lets the line break between Table and its number

    A tie, ~, keeps a label word and its number together, the kind of slip no compiler reports.

  8. Question 8 of 15 Your group wants Typst files formatted the same way. What does typstyle --check give a CI job?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: A non-zero exit status when a file is not formatted, without changing the file

    A formatter's check mode makes formatting a rule the job can test. Agree on it as a group before you turn it on.

  9. Question 9 of 15 Why does pdf.yml use runs-on: ubuntu-24.04 rather than ubuntu-latest?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: ubuntu-latest moves to newer images over time, while ubuntu-24.04 names one

    Every moving part of a build is a possible difference between two runs, the runner image included.

  10. Question 10 of 15 Which part of pdf.yml can still change from one month to the next, and how does the workflow deal with it?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: chktex from tlmgr, installed as the repository has it that day; the job records every package revision in tex-packages.txt

    What cannot be pinned is recorded. For an old paper, TUG's historic archive keeps each year's final repository in tlnet-final.

  11. Question 11 of 15 pdf.yml passed actionlint. Why did the session still run its tlmgr command on the local TinyTeX?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: actionlint checks the workflow and its shell syntax, not which fields tlmgr accepts; asked for revision, tlmgr ended with exit status 2

    A linter reads the workflow; a command's own options are tested by running it. Run every command of a workflow once on your machine, with the pinned tools, before the CI job relies on it.

  12. Question 12 of 15 How many days does GitHub keep an artifact by default, before retention-days or a repository setting changes it?

    Type a number.

    Show the answer: Question 12 of 15

    Answer: 90 days

    The upload-artifact README says artifacts are retained for 90 days by default; pdf.yml sets retention-days: 30.

  13. Question 13 of 15 Which value of SOURCE_DATE_EPOCH lets two people rebuild the same PDF of a commit a year apart?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: The time of the commit, from git log -1 --format=%ct

    The commit's time is fixed for a given set of sources, which is exactly the property a reproducible build needs.

  14. Question 14 of 15 A report prints \today under its title. With SOURCE_DATE_EPOCH set, it is rebuilt a month later. What changes?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: The printed date, because SOURCE_DATE_EPOCH fixes only the PDF's metadata unless FORCE_SOURCE_DATE=1 is set too

    Write the date in the source, as paper.tex does with \date{Field note 3}, and the page stops depending on the day of the build.

  15. Question 15 of 15 What does it mean that a/paper.pdf and b/paper.pdf had the same SHA-256 hash?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: The two files are the same byte for byte

    A hash comparison is the strictest test of a reproducible build, which is why make repro and the exercise use one.

Interview questions

Warm-up (fresher to 2–5 years): What does SOURCE_DATE_EPOCH do, and what value should a CI job give it?

It is an environment variable defined by the Reproducible Builds project: a time in seconds since the Unix epoch, the format date +%s prints. Build tools that support it, such as pdfTeX and Typst, write that time into their output in place of the current time, so the PDF’s creation and modification dates, and the IDs made from them, stop changing from run to run. A CI job should set it to the time of the last commit, git log -1 --format=%ct, because that value changes only when the sources do. In this lesson’s session, two builds without it differed in CreationDate, ModDate and ID; with the commit’s time both PDFs matched byte for byte. It does not change what \today prints, so the date on the page belongs in the source.

Two harder questions come next: a paper whose PDF changes with every build, and the CI of a lab’s thesis repository. Write down how you would begin on each, then compare it with the first step given:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A journal's production office asks you to confirm that the PDF of your accepted paper was built from tag v2. You rebuild the tag and the SHA-256 differs from the PDF you sent last month. Work out why, and make every later rebuild of v2 give the same file.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A lab keeps the LaTeX and Typst theses of its eight doctoral students in one repository. Design the CI so that every push builds each thesis, fails on undefined references and lint findings, keeps the PDFs, and can rebuild a submitted thesis exactly a year later.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • latexmk reads latexmkrc from the project folder, so the engine and the flags travel with the sources, and a Makefile gives LaTeX and Typst one command; the recorded build ran pdflatex three times, BibTeX twice and Typst once.
  • An undefined reference prints ?? and ends pdfLaTeX with exit status 0; $warnings_as_errors = 1 made latexmk end with exit status 12, and Typst stops with an error by itself.
  • chktex reports typing slips that compile without a warning, one line each with -v0, and ends with exit status 2 when it finds any, so make lint can fail a job.
  • A CI job is only as repeatable as its versions: a fixed runner image, actions by commit SHA, TinyTeX and Typst checked against their SHA-256 values, and a list of every TeX package that tlmgr added.
  • Two builds differ in their dates and IDs until SOURCE_DATE_EPOCH holds the commit’s time; then pdfLaTeX and Typst give the same bytes in any folder, and LuaLaTeX in the same folder.

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Where does latexmk read a project's settings from?

    A latexmkrc or .latexmkrc file in the folder it runs in, so the engine and its flags travel with the sources; its output then says Rc files read (in order): ./latexmkrc.

  2. What exit status does pdfLaTeX give for an undefined reference?

    0, with ?? in the PDF and a warning in the log, which a script reads as success.

  3. How do you make latexmk fail on an undefined reference?

    $warnings_as_errors = 1 in latexmkrc, or latexmk -Werror; the build then ended with exit status 12. Typst stops with an error by itself.

  4. What does .DELETE_ON_ERROR do in a Makefile?

    It makes make delete a target whose recipe failed, so a PDF with ?? in it never sits in the folder looking finished.

  5. What does chktex find, and how does it fail a CI job?

    Typing slips that compile without a warning, such as a hyphen in a range, a full stop after e.g. or straight quotation marks; with any finding it ends with exit status 2.

  6. What does a pinned CI job fix in place?

    The runner image, such as ubuntu-24.04, each action by its full commit SHA, the TinyTeX and Typst downloads checked against their SHA-256 values, and a list of every TeX package that tlmgr added.

  7. Why do two builds of one commit differ, and what makes them match?

    pdfTeX and Typst write the build time into the PDF's dates and IDs. SOURCE_DATE_EPOCH set to the commit's time, git log -1 --format=%ct, gave the same bytes; LuaLaTeX's ID also depends on the folder.

  8. Does SOURCE_DATE_EPOCH change what \today prints?

    No: it fixes only the dates in the PDF's metadata. Write the date you want on the page into the source.

Terms in this lesson

build artifact
A file that a CI job keeps after it ends, such as a built PDF, for people to download from the run or for later jobs to use; it expires after a set number of days.
continuous integration (CI)
Building and checking a project automatically on a server every time a change is pushed, so that a change that breaks the build is found at once rather than before a deadline.
pin
To fix a tool, a package or an action at one exact version, or a download at one exact file by its hash, so that every build uses it until the pin is changed on purpose.
SOURCE_DATE_EPOCH
An environment variable that holds a time as seconds since the Unix epoch. Build tools that support it, such as pdfTeX and Typst, write that time into their output instead of the time of the build.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.