CI (GitHub Actions, etc.)

«En mi máquina compila». Quien haya escrito un documento LaTeX con otra persona acaba diciendo esta frase. Compilar en CI (continuous integration, integración continua) consiste en convertirla en algo que una máquina pueda comprobar: en cada push, un entorno TeX Live limpio vuelve a descargar el repositorio desde cero y responde en tu lugar si el PDF se reproduce de verdad. El TeX Live del coautor lleva otra versión de un paquete; \setmainfont apunta a una tipografía que solo existe en tu portátil; el .bbl nunca se confirmó. Todo ello es de lo más corriente, y nada de ello se reproduce precisamente en la máquina que lo provocó. Esta página va del workflow de GitHub Actions más pequeño que produce un PDF a las tres formas de llevar TeX Live a un runner, el cache y la distribución del resultado, y termina en el fallo más incómodo de todos: la CI está verde y el PDF está roto.

¿Por qué compilar LaTeX en CI?

Hay una sola razón: tu propia máquina no sirve de prueba. La salida de un documento LaTeX no la determina únicamente su fuente. Depende de qué año de TeX Live esté instalado, de la revisión de cada paquete concreto, de las tipografías registradas en el sistema e incluso de un .sty viejo olvidado en algún punto de TEXINPUTS. Por eso «a mí me compiló» arrastra siempre una cláusula tácita muy larga: «en mi TeX Live de 2024, con el archivo de clase que dejé caer a mano hace tres años». La CI es la máquina que enuncia esa cláusula cada vez. El trabajo arranca desde un contenedor vacío y no ve nada más que el contenido del repositorio, así que si sale un PDF queda demostrado que el contenido del repositorio basta para producirlo.

El mayor ejemplo real de esta idea es arXiv. arXiv no publica sin más el PDF que subes: recompila el código LaTeX enviado en sus propios servidores. Y los autores solo pueden elegir entre dos versiones de TeX Live, cada una congelada en un estado fechado concreto. Resulta revelador que lo primero que hiciera el mayor servidor de compilación de LaTeX del mundo fuera fijar su entorno. La CI es la herramienta que permite hacer lo mismo en el repositorio propio, con una ventaja añadida: coautores y revisores sin TeX instalado siempre pueden recibir el PDF vigente. La compilación se describe en un archivo YAML situado bajo .github/workflows/.

El workflow de GitHub Actions más pequeño que produce un PDF

Solo hacen falta tres pasos: traer la fuente, compilarla y conservar el PDF. Coloca el siguiente YAML como .github/workflows/build.yml y eso es todo. actions/checkout despliega el repositorio en el runner, xu-cheng/latex-action compila dentro de un contenedor que ya trae TeX Live, y actions/upload-artifact fija el PDF resultante a la página de la ejecución del workflow. El disparador es on: [push, pull_request] para que un PDF roto no llegue nunca a la revisión.

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

root_file es la única entrada obligatoria de xu-cheng/latex-action. Lo que realmente se ejecuta dentro es latexmk, con los argumentos por defecto -pdf -file-line-error -halt-on-error -interaction=nonstopmode: es decir, pdfLaTeX, ya configurado para detenerse en cuanto aparece un error. -file-line-error reescribe los errores en la forma file:line: message, lo que hace mucho más legible un registro de CI. Para cambiar de motor, establece latexmk_use_xelatex: true o latexmk_use_lualatex: true; para fijar el año de TeX Live, texlive_version. La base es Alpine Linux por defecto y se cambia con os: debian. Los paquetes de sistema adicionales entran por extra_system_packages y las tipografías propias por extra_fonts.

Cuando el procedimiento mismo se aparta del estándar —un documento japonés que combine upLaTeX con dvipdfmx, por ejemplo—, lo más seguro es confirmar un .latexmkrc en el repositorio. Así la CI y tu portátil leen el mismo archivo de configuración y no hay un segundo sitio que arreglar; cómo se escribe esa configuración corresponde a la página de compilaciones automatizadas. Otra cosa que conviene saber si una plantilla ha de durar: las versiones mayores de las actions se mueven. actions/checkout, por ejemplo, cambió de comportamiento en v7 y ahora se niega por defecto a hacer checkout del código de una pull request procedente de un fork cuando el workflow lo dispara pull_request_target o workflow_run. Fija una cita anual para releer los README oficiales, o acepta las pull requests de actualización de dependencias.

Tres maneras de llevar TeX Live al runner

Hay tres opciones: dejárselo a la action, instalar TeX Live tú mismo en el runner, o ejecutar el trabajo dentro de una imagen Docker que ya lo contenga. Cuál elegir depende de hasta qué punto quieras decidir el entorno y de cuánto estés dispuesto a esperar en cada ejecución. Porque TeX Live es enorme. Medida sobre una instalación local completa, la versión 2024 con documentación y fuentes alcanza los 8,7 GB. La imagen texlive/texlive que distribuye la Island of TeX prescinde de documentación y fuentes y aun así pesa unos 2,5 GB comprimidos en Docker Hub. Esa cifra decide casi todo lo que viene después.

EnfoqueDe dónde sale TeX LiveConviene cuando
xu-cheng/latex-actionLa action descarga por su cuenta una imagen Docker con TeX LiveSe busca el camino más corto a una compilación que funcione, configurada solo con root_file
TeX-Live/setup-texlive-actionInstala en el runner con tlmgr y cachea TEXDIRSolo se quieren los paquetes realmente usados, o runners distintos de Linux
texlive/texliveUna imagen Docker de la Island of TeX declarada como container: del trabajoSe quiere decidir el contenido del contenedor o congelarlo en un tag con fecha

texlive/texlive se distribuye tanto en Docker Hub como bajo registry.gitlab.com/islandoftex/images/texlive, y el tag por defecto corresponde al esquema full, pero sin documentación ni fuentes. Existen las variantes -doc, -src y -doc-src si hacen falta, y cada una pesa sensiblemente más. Como latest se reconstruye cada semana, todo lo que tenga fecha de entrega conviene fijarlo a un tag de instantánea con fecha del tipo TL2022-2022-06-05. Para reproducir un año antiguo tal cual, se conservan tags históricos como TL2018-historic. Declara la imagen en el container: del trabajo y todos los pasos posteriores se ejecutan dentro de ella.

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

Cachear la instalación de TeX Live para acortar la espera

Si usas TeX-Live/setup-texlive-action, el cache ya está activo. La entrada cache de la action vale true por defecto; internamente llama a @actions/cache y guarda TEXDIR entero. El guardado ocurre en la fase de posprocesado, una vez terminado el trabajo, de modo que lo generado durante la compilación —los cachés de fuentes, por ejemplo— también se conserva. Cualquier ejecución posterior a la primera se ahorra así toda la descarga desde un espejo de tlmgr. Se desactiva con cache: false. Un apunte: esta action estuvo antes en teatimeguest/setup-texlive-action y desde entonces se ha trasladado a la organización TeX-Live, así que el YAML copiado de un artículo antiguo no resolverá.

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

El modo habitual de usar esta action es poner scheme-basic en packages y añadir encima lo necesario. En lugar de arrastrar los 8 GB completos, enumera solo lo que de verdad cargas con \usepackage: biber, biblatex, siunitx y similares. Cuando la lista se alarga, package-file la saca fuera apuntando a un archivo como .github/tl_packages o a un patrón del tipo **/DEPENDS.txt. La entrada version fija el año, lo que reproduce a pequeña escala lo que hace arXiv: puedes decir «compila en TeX Live 2025» en vez de «compila en latest». Si en cambio eliges la vía Docker, intentar cachear la propia imagen con actions/cache es una solución mal encaminada: fija el tag y deja que el pull del registro se ocupe.

Repartir el PDF: ¿artifact o release?

Estos dos mecanismos evitan tener que decirle a un revisor «instala primero TeX Live». Con actions/upload-artifact el PDF queda fijado a la página de la ejecución del workflow y cualquiera con acceso al repositorio puede descargarlo. La retención sigue la configuración del repositorio, con un tope de 90 días. Hay aquí un ajuste pequeño que pesa más de lo que parece: archive vale true por defecto, así que el artefacto se comprime en zip antes de subirse y quien lo recoge descarga un zip en vez de main.pdf. Con archive: false sube un único archivo tal cual, lo que ahorra un paso a quien está al otro lado.

Para una versión que se reparte de verdad, encaja mejor una release. Los artifacts desaparecen al expirar la retención; un PDF adjunto a una release no, recibe una URL permanente y es accesible desde la portada del repositorio. La forma habitual es «empuja un tag de versión y obtén una release», y como las imágenes de runner Ubuntu alojadas por GitHub ya traen la GitHub CLI (gh) preinstalada, basta una línea sin ninguna action adicional. Crear una release es, eso sí, una operación de escritura: hay que elevar permissions: a contents: write y aportar un GH_TOKEN. Deja el workflow de compilación en contents: read y pon el trabajo de publicación en un archivo aparte.

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 }}
  • Añade siempre if-no-files-found: error. El valor por defecto es warn, así que una errata en path: deja el workflow en verde igualmente.
  • Con archive: false el PDF sube tal cual en lugar de dentro de un zip (solo archivos únicos).
  • Incluye la configuración de compilación (.latexmkrc y demás) en el repositorio: local y CI siguen así los mismos pasos y desaparece una fuente de divergencia.
  • Fija la versión de TeX Live en todo lo que entregues, mediante texlive_version, mediante la entrada version de setup-texlive-action, o mediante un tag de imagen con fecha.
  • Mantén permissions: en contents: read por defecto y elévalo a contents: write solo en el trabajo que crea la release.

Cuando CI falla, y cuando está verde pero el PDF está roto

Abre primero el registro y busca las líneas que empiezan por !. Todo error de LaTeX se anuncia con esa forma, así que es localizable entre cientos de líneas. Los síntomas habituales se reducen a cinco o seis, y cada uno se corresponde casi uno a uno con su causa.

Lo que dice el registroQué ha ocurrido en realidadQué hacer
! LaTeX Error: File `...sty' not found.El TeX Live de la CI no tiene ese paqueteAñádelo a packages, usa extra_system_packages o cambia a una imagen de esquema full
! Undefined control sequence.Una errata en el documento, o el paquete que aporta la orden no se cargóRevisa la ortografía de esa línea y la lista de \usepackage; debería reproducirse también en local
! Package fontspec Error: The font "..." cannot be found.Esa tipografía no está en el contenedor; en local venía del sistema operativoConfirma la tipografía en el repositorio y pásala con extra_fonts, o cambia a una que venga con TeX Live
LaTeX Warning: There were undefined references.Solo un aviso: el estado de salida es 0 y el PDF conserva los ??Deja que latexmk haga las pasadas necesarias; para que falle, filtra el registro con grep y sal con código distinto de cero
No files were found with the provided pathEl path: dado a upload-artifact no coincide con el nombre real de salidaComprueba dónde acaba realmente el PDF; sin if-no-files-found: error esto termina en verde igualmente
! Emergency stop.TeX cayó en su prompt interactivo y la CI no tiene terminal con el que responderAñade -interaction=nonstopmode; latex-action ya lo pasa por defecto

Conviene deshacer aquí un malentendido frecuente sobre -interaction=nonstopmode: la opción no se traga los errores. Pasa por pdflatex -interaction=nonstopmode un documento con una orden no definida y el estado de salida es honestamente 1. Lo que sí hace es escribir un PDF de todos modos: se salta el punto defectuoso y llega hasta el final del documento. Así que lo único que hace realmente la opción es renunciar a detenerse para preguntar a una persona; no se pierde información sobre el éxito o el fracaso. El mismo documento pasado por latexmk -pdf termina con código distinto de cero y no deja ningún PDF. En CI ese es el comportamiento deseable, y -halt-on-error corta además en el primer error.

Lo que de verdad se rompe en silencio no es el error, sino el aviso. Cuando un \ref o un \cite no se resuelve, LaTeX solo emite LaTeX Warning: There were undefined references. y el estado de salida es 0. La CI se pone verde y el artefacto es un PDF plagado de ??. Súmale el valor por defecto warn de if-no-files-found y puedes tener un workflow con un path: mal escrito que acaba verde de principio a fin sin producir absolutamente nada. Para poder fiarte del verde, escribe como mínimo if-no-files-found: error y deja que latexmk decida cuántas pasadas necesita el documento.

Queda el último caso: compila en tu equipo y falla solo la CI. La causa es casi siempre una dependencia de algo que no está en el repositorio. Una imagen o un .bbl ya generado que solo vive en tu disco; un archivo generado que .gitignore se tragó en silencio; y, muy a menudo, las mayúsculas y minúsculas de un nombre de archivo. Los sistemas de archivos por defecto de macOS y Windows no distinguen mayúsculas, así que \includegraphics{Figure1} encuentra sin problema figure1.pdf en tu máquina, mientras que en el Linux del runner son dos nombres distintos. Cuando la CI falle, pregúntate primero si eso está siquiera en el repositorio. Que es, vuelto del revés, exactamente el valor de la CI: hace esa pregunta por ti en cada push.