rules_latex_host 0.3.1Latest published <1h ago
MODULE.bazel
bazel_dep(name = "rules_latex_host", version = "0.3.1")
README

rules_latex_host

Test Tag and Release Publish Publish on Bazel Central Registry

Bazel rules that build LaTeX PDFs from pdflatex plus poppler-utils (pdfinfo, pdfunite) and ghostscript (gs). Those tools come from a Bazel toolchain, and there are two implementations:

  • host (the default, hence the name) — thin wrappers around the binaries installed on the machine. Deliberately non-hermetic; nothing to download.
  • hermetic — a pinned TeX distribution and PDF utilities that Bazel fetches, so the build needs nothing installed and can run sandboxed. See Hermetic toolchain.

Document targets are identical either way: the rules depend on a toolchain type, never on a binary, so switching is a MODULE.bazel edit.

What you get

  • latex_document(name, main, deps, data) — compiles main.texmain.pdf with three pdflatex passes (resolves cross-references, longtable continued-headers, and the in-document thebibliography). deps are the other .tex files the master \inputs, e.g. glob(["sections/*.tex"]). data is everything else the document reads: figures, a bibliography, a class or style file, e.g. glob(["figures/*.png"]). Both are copied into the work directory under their package relative paths, so \includegraphics{figures/plot} finds figures/plot.png.
  • combined_pdf(name, out, parts) — concatenates several PDFs with pdfunite and adds a top-level PDF outline via ghostscript.
  • A //latex:toolchain_type and a system_latex toolchain, auto-registered by the module.
  • A hermetic_latex module extension that vendors the tools instead.

Prerequisites

Install the host tools once:

bazel run @rules_latex_host//latex:install_tools
# or:  bash latex/install-tools.sh

(TeX Live + poppler-utils + ghostscript, via the platform package manager.) Nothing to install if you use the hermetic toolchain.

Usage

.bazelrc — this module lives in a custom registry, so name it. Naming any --registry replaces the built-in default rather than adding to it, so BCR has to be listed again or the module's own dependencies stop resolving. Registries are consulted in the order given:

common --registry=https://raw.githubusercontent.com/filmil/bazel-registry/main
common --registry=https://bcr.bazel.build

MODULE.bazel:

bazel_dep(name = "rules_latex_host", version = "0.0.5")

BUILD.bazel:

load("@rules_latex_host//latex:defs.bzl", "latex_document", "combined_pdf")

latex_document(
    name = "paper",
    main = "paper.tex",
    deps = glob(["sections/*.tex"]),   # .tex files the master \input's
    data = glob(["figures/*.png"]),    # everything else it reads
)
bazel build //:paper      # -> bazel-bin/paper.pdf

The system_latex toolchain registers automatically, so no toolchain wiring is needed in the consumer.

Hermetic toolchain

The host tools are the default, not the only option. The hermetic_latex module extension fetches a pinned TeX distribution and PDF utilities and binds them into a toolchain, so a build needs nothing installed on the machine and its actions run in the sandbox. No document target changes — the rules name a toolchain type, never a binary.

In your MODULE.bazel:

bazel_dep(name = "rules_latex_host", version = "0.0.5")

hermetic_latex = use_extension(
    "@rules_latex_host//latex:extensions.bzl",
    "hermetic_latex",
)
hermetic_latex.toolchain()
use_repo(hermetic_latex, "hermetic_latex")

register_toolchains("@hermetic_latex//:all")

That is the whole change. Skip install_tools; bazel build //:paper now compiles with the downloaded engine. To see which one actually ran, compare pdfinfo bazel-bin/paper.pdf | grep Producer against your host pdflatex --version.

What gets vendored

Contract in LatexInfo Provided by Why
pdflatex TinyTeX (TeX Live) the one TeX distribution published as a small relocatable per-platform tarball — it locates its own texmf-dist, so there is no install step
pdfinfo, pdfunite qpdf + shims poppler publishes no prebuilt binaries; only the page-count and concatenate behaviour the rules use is emulated
gs Ghostscript 10.0.0 the last release with a prebuilt Linux binary; used only for the combined-PDF outline

Each is fetched by SHA-256 — see latex/hermetic/versions.bzl for the pins. Ghostscript's prebuilt binaries are the binding constraint, so the shipped pins cover linux-x86_64. The generated toolchain carries matching exec_compatible_with constraints, so on any other platform it is skipped and resolution falls back to the system toolchain — a mixed fleet keeps working.

Adding packages

The default distribution (TinyTeX-1, ~54 MB) has the LaTeX base and the common packages, but not everything — IEEEtran and pgf/tikz, for instance, are not in it. Three ways to get them:

# 1. Pin the package archives. Content-addressed, so the fetch is reproducible
#    byte for byte. Dependencies are NOT resolved: list what a package needs
#    alongside it.
hermetic_latex.toolchain(
    texlive_archives = {
        "https://ftp.fau.de/ctan/systems/texlive/tlnet/archive/xcharter.tar.xz": "92ae1526...",
        "https://ftp.fau.de/ctan/systems/texlive/tlnet/archive/xstring.tar.xz": "55356a92...",
    },
)

# 2. Install at fetch time. Resolves dependencies, but costs network access to
#    a live mirror and a host perl, and is not content-addressed.
hermetic_latex.toolchain(
    texlive_packages = ["ieeetran", "pgf", "courier"],
)

# 3. Pin a bigger tarball. Content-addressed, but limited to what the
#    distribution ships: XCharter and Erewhon, for instance, are in no TinyTeX
#    variant, so this cannot reach them.
hermetic_latex.toolchain(
    texlive_url = "https://github.com/rstudio/tinytex-releases/releases/download/v2026.08/TinyTeX-linux-x86_64-v2026.08.tar.xz",
    texlive_sha256 = "...",
)

texlive_archives takes {url: sha256}. Each archive is unpacked into the distribution's texmf-dist, so it must be texmf-relative (tex/latex/..., fonts/type1/...), which is exactly the layout of TeX Live's own per-package archives. Every CTAN mirror serves them under systems/texlive/tlnet/archive/<package>.tar.xz. After unpacking, the rule runs mktexlsr so kpathsea sees the new files, and updmap-sys for any font map the package brought, without which the engine finds the metrics but cannot embed the glyphs.

Two things to know before reaching for it:

  • Do not fetch from texlive.info. Its tlnet snapshots look like the ideal source: dated, immutable, already in the right layout. But the host sits behind an anti-scraper. It answers an unrecognised client with HTTP 200 and a challenge page instead of the file. Bazel therefore fails with a checksum mismatch rather than a download error, which is a confusing way to learn this. Use a CTAN mirror, or re-host the archives yourself.
  • CTAN's tlnet tree tracks the current TeX Live, so a package's archive is replaced when it is updated upstream. The sha256 pin turns that into a loud failure rather than silent drift, but it does mean the pin needs refreshing occasionally. Re-host the archives if you need them frozen.

Note the asymmetry in what each option costs the build's reproducibility: with only texlive_archives the extension still reports reproducible, so no lockfile entry is needed. One texlive_packages entry gives that up, because a live mirror serves whatever it serves today.

Every pin is overridable the same way (qpdf_*, gs_*, exec_compatible_with, …) — see the tag attributes in latex/extensions.bzl. Pointing them at your own archives is also how you extend the toolchain to another platform.

Trying it

integration/hermetic/ is a self-standing module — its own MODULE.bazel, .bazelrc and .bazelversion, built from that directory — that compiles an IEEEtran paper with tikz figures, a plain-article note, and a combined edition of both, with no TeX installed:

cd integration/hermetic && bazel build //...

Its sibling integration/system/ does the same against the host toolchain. Copying either one out into a repository of your own takes deleting its local_path_override — see the module's README.

Rolling your own

The extension is a convenience, not a requirement. latex_toolchain (see latex/toolchain.bzl) takes four executables plus a files list of everything they need at run time; declare one over targets of your choosing and register it. The four executables only have to honour the CLI contract documented on LatexInfo — the hermetic toolchain's pdfinfo, for one, is a qpdf shim rather than poppler.

Releasing and publishing

Tag and Release in .github/workflows/tag-and-release.yml runs monthly and on workflow_dispatch. It releases only when a commit landed since the last tag, computes the version from the conventional-commit titles since that tag, and pushes it. The release itself goes through bazel-contrib's release_ruleset.yaml: it runs bazel test //... at the tag, has .github/workflows/release_prep.sh write bazel_rules_latex_host-<tag>.zip with git archive and print the release notes, and attests the archive's provenance. A release then lists the archive and bazel_rules_latex_host-<tag>.zip.intoto.jsonl.

The same run publishes the release to my Bazel registry as a pull request, through .github/workflows/publish.yml, and then opens a pull request against the Bazel Central Registry through .github/workflows/publish-bcr.yml, with attested MODULE.bazel and source.json, which is what the BCR presubmit verifies with slsa-verifier. Only that publish attests: two attesting publishes would overwrite each other's attestation files on the release. The presubmit builds @rules_latex_host//latex/..., which needs no TeX; the integration modules under integration/, which compile documents, run in this repository's own CI.

To check a release the way the BCR does, with the archive downloaded from the release:

slsa-verifier verify-github-attestation \
  --attestation-path bazel_rules_latex_host-<tag>.zip.intoto.jsonl \
  --source-uri github.com/filmil/bazel_rules_latex_host \
  --builder-id https://github.com/bazel-contrib/.github/.github/workflows/release_ruleset.yaml \
  bazel_rules_latex_host-<tag>.zip

License

Apache License 2.0 — see LICENSE.

About

Bazel rules to build LaTeX PDFs via host TeX tools (pdflatex, poppler-utils, ghostscript) behind a swappable toolchain

@filmil/bazel_rules_latex_host@filmil

Languages

Shell8.2%
TeX7.9%

Maintainers

@filmil

Versions

0.3.12026-09-09