CI (GitHub Actions 等)

「在我的机器上明明能编译」——只要和别人合写过 LaTeX 文档,迟早都会说出这句话。在 CI(continuous integration,持续集成)里构建,就是把这句话交给机器去核验:每次 push,一个干净的 TeX Live 环境都会从零重新取回整个仓库,替你回答 PDF 究竟能不能复现。合作者的 TeX Live 里装着另一个版本的宏包;\setmainfont 指向的字体只存在于你自己的笔记本上;.bbl 从来没有提交过——这些都稀松平常,而且没有一样会在闯祸的那台机器上重现。本页从能构建出 PDF 的最小 GitHub Actions workflow 讲起,谈把 TeX Live 弄到 runner 上的三种办法、缓存、产物分发,最后落到最麻烦的失败:CI 是绿的,PDF 却是坏的

为什么要在 CI 里构建 LaTeX

理由只有一个:你自己的机器不能当证据。LaTeX 的输出并不只由文档源码决定,它还依赖那台机器上装的是哪一年的 TeX Live、每个宏包的具体修订版、系统里注册的字体,甚至 TEXINPUTS 某处遗留的一个旧 .sty。所以「我这边能编译」背后总藏着一句没说出口的长长附注:「在我 2024 年的 TeX Live 上,而且三年前我手动放进去的那个类文件还在」。CI 就是每次都把这句附注挑明的装置。任务从一个空容器开始,除了仓库里的东西什么也看不见——因此只要 PDF 出得来,就等于证明了「仅凭仓库内容就能生成 PDF」。

这一思路最大的现实例子是 arXiv。arXiv 并不直接发布你上传的 PDF,而是在自己的服务器上重新编译投稿的 LaTeX 源码。而且作者能选的 TeX Live 始终只有两个版本,各自都被冻结在某个具体日期的状态上。全世界最大的 LaTeX 构建服务器最先做的事就是「把环境钉死」,这一点很说明问题。CI 就是让你在自己的仓库里做同样事情的工具,附带的好处是:没有装 TeX 的合作者与审稿人也总能拿到最新的 PDF。构建内容写在 .github/workflows/ 下的 YAML 文件里。

能构建出 PDF 的最小 GitHub Actions workflow

需要的步骤只有三个:取回源码、编译、保存 PDF。把下面的 YAML 放到 .github/workflows/build.yml,就是全部了。actions/checkout 把仓库展开到 runner 上,xu-cheng/latex-action 在一个已经装好 TeX Live 的容器里编译,actions/upload-artifact 把生成的 PDF 挂到 workflow 运行页面上。触发条件写成 on: [push, pull_request],是为了让损坏的 PDF 永远进不了评审环节。

terminal
# .github/workflows/build.yml
name: Build LaTeX
on: [push, pull_request]
permissions:
  contents: read
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: xu-cheng/latex-action@v4
        with:
          root_file: main.tex
      - uses: actions/upload-artifact@v7
        with:
          name: pdf
          path: main.pdf
          if-no-files-found: error

xu-cheng/latex-action 唯一必填的输入是 root_file。里面真正运行的是 latexmk,默认参数为 -pdf -file-line-error -halt-on-error -interaction=nonstopmode——也就是 pdfLaTeX,而且一出错就立刻停止的设置本来就已经打开。-file-line-error 会把错误提示改成 file:line: message 的形式,在翻 CI 日志时特别有用。要换引擎就设置 latexmk_use_xelatex: truelatexmk_use_lualatex: true;要钉住 TeX Live 的年份就设置 texlive_version。基础系统默认是 Alpine Linux,可以用 os: debian 切换。需要额外的系统软件包时用 extra_system_packages,要带上自己的字体则用 extra_fonts

当流程本身偏离标准时——比如把 upLaTeX 与 dvipdfmx 组合起来的日语文档——最稳妥的做法是把 .latexmkrc 提交进仓库。这样 CI 和本地读的是同一个配置文件,不必分两处修改;这份配置怎么写,交给自动构建那一页去讲。还有一点值得记住,如果模板要长期使用:action 的主版本是会变的。例如 actions/checkout 在 v7 改变了行为,当 workflow 由 pull_request_targetworkflow_run 触发时,默认不再检出 fork 的 PR 代码。要么在日历上定一个每年重读官方 README 的日子,要么允许依赖更新的 PR 进来。

把 TeX Live 弄到 runner 上的三种办法

有三个选项:交给 action 处理、自己在 runner 上安装、或者让 job 直接跑在一个已经装有 TeX Live 的 Docker 镜像里。选哪个,取决于你要多大程度自己决定环境,以及每次运行愿意等多久。因为 TeX Live 非常庞大。在本地做一次完整安装实测,含文档与源码的 TeX Live 2024 有 8.7 GB。Island of TeX 发布的 texlive/texlive 镜像已经剔除了文档和源码,在 Docker Hub 上的压缩体积仍约为 2.5 GB。下面的多数判断都由这个数字决定。

方式TeX Live 从哪来适用场景
xu-cheng/latex-actionaction 自己拉取内含 TeX Live 的 Docker 镜像想用最短路径先跑起来,配置只写 root_file
TeX-Live/setup-texlive-actiontlmgr 装到 runner 上,并缓存 TEXDIR只想装真正用到的宏包,或者需要 Linux 以外的 runner
texlive/texlive指定为 job 的 container: 的 Island of TeX 官方镜像想自己决定容器内容,或用带日期的 tag 冻结环境

texlive/texlive 同时发布在 Docker Hub 和 registry.gitlab.com/islandoftex/images/texlive,默认标签是 full 方案——但去掉了文档和源码。若确有需要,还有 -doc-src-doc-src 三种风味,代价是体积必然更大。由于 latest 每周都会重建,有截稿期的成果最好固定到 TL2022-2022-06-05 这样的带日期快照标签。想原样重现过去某一年,则有 TL2018-historic 这类历史标签可用。把镜像名写进 job 的 container:,之后的每一步都会在其中运行。

terminal
# pin the image; latest is rebuilt weekly
jobs:
  build:
    runs-on: ubuntu-latest
    container: texlive/texlive:latest
    steps:
      - uses: actions/checkout@v7
      - run: latexmk -pdf -halt-on-error -interaction=nonstopmode main.tex
      - uses: actions/upload-artifact@v7
        with:
          name: pdf
          path: main.pdf
          if-no-files-found: error

缓存 TeX Live 安装以缩短等待

如果使用 TeX-Live/setup-texlive-action,缓存本来就是开着的。该 action 的 cache 输入默认值为 true,内部调用 @actions/cache,把整个 TEXDIR 保存下来。保存发生在任务结束后的后处理阶段,因此构建过程中生成的东西——比如字体缓存——也会一并带走。于是从第二次构建起,就能完全跳过 tlmgr 从镜像下载的时间。要关掉就写 cache: false。另外要注意:这个 action 原先位于 teatimeguest/setup-texlive-action,现已迁到 TeX-Live 组织下——照抄旧文章里的 YAML 是解析不到的。

terminal
      - uses: TeX-Live/setup-texlive-action@v4
        with:
          version: 2025
          packages: |
            scheme-basic
            latexmk
            biber
            biblatex
      - run: latexmk -pdf -halt-on-error -interaction=nonstopmode main.tex

这个 action 的惯用做法,是在 packages 里放上 scheme-basic,再往上加需要的东西。与其把 8 GB 全拖过来,不如只列出你真正 \usepackage 的那些——biberbiblatexsiunitx 之类。列表变长之后,可以用 package-file 指向 .github/tl_packages 这样的文件或 **/DEPENDS.txt 这样的模式,把它挪到外面。version 用来钉住年份,这正是 arXiv 做法的小型复刻:你可以说「它能在 TeX Live 2025 上构建」,而不是「它能在 latest 上构建」。如果走 Docker 路线,试图用 actions/cache 缓存镜像本身是走偏了——固定 tag,把拉取交给 registry 更为自然。

把生成的 PDF 交出去:artifact 还是 Release?

这两样东西,能让你不必对审稿人说「请先装 TeX Live」。用 actions/upload-artifact,PDF 会挂在 workflow 运行页面上,凡是能看到仓库的人都能下载。保留期由仓库设置决定,上限为 90 天。这里有一个不起眼却很管用的设置:archive 的默认值是 true,产物会先打成 zip 再上传,于是对方下载到的是 zip 而不是 main.pdf。设成 archive: false 就能把单个文件原样上传,替接收方省掉一步。

若要作为公开版本分发,Release 更合适。artifact 到期就会消失,而附加在 Release 上的 PDF 不会,它有固定的 URL,并且能从仓库首页直接找到。惯常的做法是「推送版本 tag 就生成 Release」,而 GitHub 托管的 Ubuntu runner 镜像本身就预装了 GitHub CLI(gh),所以一行就够,不必再引入额外的 action。不过创建 Release 属于写操作,需要把 permissions: 提升为 contents: write 并传入 GH_TOKEN。稳妥的做法是让构建用的 workflow 保持 contents: read,把发布任务放进另一个文件。

terminal
# .github/workflows/release.yml
name: Release PDF
on:
  push:
    tags: ["v*"]
permissions:
  contents: write
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: xu-cheng/latex-action@v4
        with:
          root_file: main.tex
      - run: gh release create "$GITHUB_REF_NAME" main.pdf
        env:
          GH_TOKEN: ${{ github.token }}
  • 一定要加上 if-no-files-found: error。默认值是 warnpath: 写错了 workflow 照样是绿的。
  • 设成 archive: false,PDF 就会原样上传而不是包在 zip 里(仅限单个文件)。
  • 把构建配置(.latexmkrc 之类)随仓库一起提交,让本地和 CI 走同样的步骤,少掉一个产生差异的来源。
  • 凡是要提交的成果,都要钉住 TeX Live 版本:可以用 texlive_versionsetup-texlive-actionversion,或者带日期的镜像 tag。
  • permissions: 默认保持 contents: read,只在创建 Release 的那个 job 里提升为 contents: write

CI 失败时,以及 CI 是绿的但 PDF 已经坏了时

先打开日志,去找以 ! 开头的行。LaTeX 的错误一定以这种形式出现,所以哪怕在几百行里也能一眼找到。常见症状不过五六种,而且每一种几乎都对应唯一的原因。

日志中的字样实际发生了什么如何处理
! LaTeX Error: File `...sty' not found.CI 那边的 TeX Live 里没有这个宏包加进 packages,使用 extra_system_packages,或改用 full 方案的镜像
! Undefined control sequence.文档里打错了字,或者提供该命令的宏包没有加载检查该行拼写与 \usepackage 列表;这个错误在本地也应当能复现
! Package fontspec Error: The font "..." cannot be found.容器里没有这个字体;在本地它来自操作系统把字体提交进仓库并用 extra_fonts 传入,或改用 TeX Live 自带的字体
LaTeX Warning: There were undefined references.只是警告:退出状态为 0,而 PDF 里留着 ??交给 latexmk 跑够所需遍数;若想让构建失败,就 grep 日志并返回非零
No files were found with the provided pathupload-artifactpath: 与实际输出名对不上确认 PDF 实际落在哪里;若没设 if-no-files-found: error,这仍然会以绿色结束
! Emergency stop.TeX 掉进了交互提示,而 CI 没有终端可以应答加上 -interaction=nonstopmodelatex-action 默认已经带上了

这里要澄清一个关于 -interaction=nonstopmode 的常见误解:这个选项并不会吞掉错误。把含有未定义命令的文档交给 pdflatex -interaction=nonstopmode,退出状态老老实实就是 1。但与此同时,它仍然会输出 PDF——因为它会跳过出错处,一路跑到文档末尾。所以这个选项真正做的只是「不停下来问人」,成败信息并没有丢失。把同一份文档交给 latexmk -pdf,则既以非零退出,也不会留下 PDF。在 CI 里正是后一种行为更可取,再配上 -halt-on-error 就能在第一个错误处截断。

真正会悄无声息出问题的不是错误,而是警告。当 \ref\cite 的引用没能解析时,LaTeX 只会给出 LaTeX Warning: There were undefined references. 这一句警告,退出状态是 0。CI 变绿,artifact 里上传的是一份满是 ?? 的 PDF。再叠上 if-no-files-found 默认的 warn,甚至会出现这样的局面:path: 写错的 workflow 从头绿到尾,却什么也没留下。要让绿色可信,至少得写上 if-no-files-found: error,并把编译遍数交给 latexmk 决定。

最后是剩下的那一类:本地能编译,偏偏只有 CI 失败。原因几乎总是依赖了不在仓库里的东西。只存在于自己硬盘上的图片或事先生成的 .bbl;被 .gitignore 悄悄挡下的生成文件;还有很常见的一种——文件名大小写。macOS 与 Windows 的默认文件系统不区分大小写,所以 \includegraphics{Figure1} 在你机器上能顺利找到 figure1.pdf,但在 runner 的 Linux 上那是两个不同的名字。CI 一失败,先问一句「这东西在仓库里吗」。反过来说,这正是 CI 的价值所在:它每次 push 都替你问一遍。