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.
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:
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:
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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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:
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
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: 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} 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
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:
# 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:
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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:
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.
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
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:
"""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'}") 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
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.
- Find the undefined references and citations in a log To do
- 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: Referencesec: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: Citationrao2025gauge' 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`.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
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.
-
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.
-
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.
Key takeaways
- latexmk reads
latexmkrcfrom 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 = 1made 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, somake lintcan 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_EPOCHholds 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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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
- latexmk: fully automated LaTeX document generation (CTAN)
- GNU make manual: special built-in target names (.DELETE_ON_ERROR) (GNU Project)
- chktex: check for errors in LaTeX documents (CTAN)
- latexindent: indent a LaTeX document, highlighting the programming structure (CTAN)
- typstyle: a code formatter for Typst (typstyle project)
- SOURCE_DATE_EPOCH specification (Reproducible Builds)
- The pdfTeX user manual (environment variables, \pdftrailerid) (CTAN)
- Typst: the compiler and its command-line interface (Typst)
- TinyTeX releases (v2026.10: TinyTeX-0, TinyTeX-1 and TinyTeX bundles) (TinyTeX project)
- TeX Live availability (past releases and tlnet-final) (TeX Users Group)
- Workflow syntax for GitHub Actions (GitHub)
- Evaluate expressions in workflows and actions (status check functions) (GitHub)
- GitHub-hosted runners (GitHub)
- Secure use reference (pinning actions to a commit SHA) (GitHub)
- actions/checkout: README (fetch-depth, persist-credentials) (GitHub)
- actions/upload-artifact: README (retention-days, if-no-files-found) (GitHub)
- actionlint: static checker for GitHub Actions workflow files (actionlint project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress