CI(GitHub Actions 등)

“제 환경에서는 컴파일이 됩니다”——LaTeX 문서를 누군가와 함께 쓰다 보면 언젠가는 반드시 하게 되는 말입니다. CI(continuous integration, 지속적 통합)에서 빌드한다는 것은 이 말을 기계가 검증할 수 있는 형태로 바꾸는 일입니다. push할 때마다 깨끗한 TeX Live 환경이 저장소를 처음부터 다시 받아 와서, PDF가 정말로 재현되는지를 대신 대답해 줍니다. 공동 저자의 TeX Live에는 다른 버전의 패키지가 들어 있고, \setmainfont가 가리키는 글꼴은 내 노트북에만 있으며, .bbl은 커밋된 적이 없습니다. 모두 흔한 일이고, 모두 사고를 낸 바로 그 기계에서는 결코 재현되지 않습니다. 이 페이지는 PDF를 만들어 내는 가장 작은 GitHub Actions workflow에서 출발해, runner에 TeX Live를 올리는 세 가지 방법과 캐시, 결과물 배포를 거쳐, 가장 성가신 실패——CI는 초록인데 PDF가 망가져 있는 경우——까지 살펴봅니다.

왜 LaTeX을 CI에서 빌드하는가

이유는 하나입니다. 내 컴퓨터는 증거가 되지 않습니다. LaTeX의 출력은 문서 소스만으로 결정되지 않습니다. 그 기계에 설치된 TeX Live의 연도, 개별 패키지의 리비전, 시스템에 등록된 글꼴, 심지어 TEXINPUTS 어딘가에 남아 있는 낡은 .sty까지가 결과를 좌우합니다. 그래서 “내 쪽에서는 빌드됐다”라는 보고에는 늘 긴 단서가 숨어 있습니다. “2024년판 내 TeX Live에서, 3년 전에 손으로 넣어 둔 클래스 파일이 있는 상태에서”라는 단서입니다. 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_target이나 workflow_run으로 트리거된 경우 fork의 PR 코드를 기본적으로 체크아웃하지 않습니다. 해마다 공식 README를 다시 읽는 날을 정해 두거나, 의존성 업데이트 PR을 받아들이도록 설정해 둡니다.

runner에 TeX Live를 올리는 세 가지 방법

선택지는 셋입니다. action에 맡기거나, runner에 직접 설치하거나, TeX Live가 이미 들어 있는 Docker 이미지를 job의 컨테이너로 삼는 것입니다. 무엇을 고를지는 “환경을 어디까지 직접 정하고 싶은가”와 “매번 얼마나 기다릴 수 있는가”로 갈립니다. 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/texlivejob의 container:로 지정하는 Island of TeX의 Docker 이미지컨테이너 내용을 직접 정하고 싶거나 날짜 태그로 환경을 고정하고 싶을 때

texlive/texlive는 Docker Hub와 registry.gitlab.com/islandoftex/images/texlive 양쪽에서 배포되며, 기본 태그는 full 스킴——다만 문서와 소스를 뺀 것——입니다. 필요하면 -doc, -src, -doc-src 플레이버가 있지만 그만큼 확실히 무거워집니다. latest는 매주 다시 만들어지므로, 마감이 걸린 제출물은 TL2022-2022-06-05 같은 날짜 스냅숏 태그에 고정하는 편이 안전합니다. 지난 연도를 그대로 재현하고 싶다면 TL2018-historic 같은 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의 관용은 packagesscheme-basic을 두고 필요한 것을 그 위에 더하는 방식입니다. 8 GB 전부를 끌어오는 대신, 실제로 \usepackage하는 것만 나열합니다——biber, biblatex, siunitx 같은 것들입니다. 목록이 길어지면 package-file.github/tl_packages 같은 파일이나 **/DEPENDS.txt 같은 패턴을 가리켜 밖으로 빼낼 수 있습니다. version 입력은 연도를 고정하며, 이것이 arXiv가 하는 일의 축소판입니다. “latest에서 빌드된다”가 아니라 “TeX Live 2025에서 빌드된다”라고 말할 수 있게 되는 것입니다. 반대로 Docker 쪽을 택했다면 actions/cache로 이미지 자체를 캐시하려는 시도는 방향이 어긋납니다. 태그를 고정하고 레지스트리의 pull에 맡기는 편이 자연스럽습니다.

만들어진 PDF를 건네기: artifact인가 Release인가

검토자에게 “먼저 TeX Live를 설치해 주세요”라고 말하지 않아도 되게 해 주는 것이 이 둘입니다. actions/upload-artifact를 쓰면 PDF가 workflow 실행 페이지에 붙고, 저장소를 볼 수 있는 사람이면 누구나 내려받을 수 있습니다. 보존 기간은 저장소 설정을 따르며 상한은 90일입니다. 여기에 겉보기보다 중요한 설정이 하나 있습니다. archive의 기본값이 true라서 결과물은 zip으로 묶인 뒤 올라가고, 받는 쪽은 main.pdf가 아니라 zip을 내려받게 됩니다. archive: false로 두면 단일 파일이 그대로 올라가므로 상대방의 수고가 한 단계 줄어듭니다.

공개판으로 배포할 것이라면 Release 쪽이 어울립니다. artifact는 보존 기간이 끝나면 사라지지만, Release에 첨부한 PDF는 사라지지 않고 영구적인 URL이 붙으며 저장소 첫 화면에서 바로 찾아갈 수 있습니다. 관례적인 형태는 “버전 태그를 push하면 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를 반드시 붙입니다. 기본값이 warn이라 path:를 잘못 적어도 workflow는 초록으로 끝납니다.
  • archive: false로 두면 PDF가 zip이 아니라 그대로 올라갑니다(단일 파일일 때만).
  • 빌드 설정(.latexmkrc 등)은 저장소에 함께 넣습니다. 로컬과 CI가 같은 단계를 밟게 되어 차이가 생길 원인이 하나 줄어듭니다.
  • 제출물이라면 TeX Live 버전을 고정합니다. texlive_version, setup-texlive-actionversion, 또는 날짜가 붙은 이미지 태그 중 하나로 지정할 수 있습니다.
  • permissions:는 기본을 contents: read로 두고, Release를 만드는 job에서만 contents: write로 올립니다.

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해 0이 아닌 값으로 끝냅니다
No files were found with the provided pathupload-artifact에 준 path:와 실제 출력 이름이 어긋남PDF가 실제로 어디에 떨어지는지 확인합니다. if-no-files-found: error가 없으면 그대로 초록으로 끝납니다
! Emergency stop.TeX이 대화형 프롬프트로 떨어졌는데 CI에는 응답할 터미널이 없음-interaction=nonstopmode를 붙입니다. latex-action은 기본으로 붙여 줍니다

여기서 -interaction=nonstopmode에 관한 흔한 오해를 하나 풀어 두겠습니다. 이 옵션은 오류를 뭉개지 않습니다. 정의되지 않은 명령이 든 문서를 pdflatex -interaction=nonstopmode에 통과시키면 종료 상태는 정직하게 1이 됩니다. 다만 그러면서도 PDF는 만들어져 버립니다. 오류 지점을 건너뛰고 문서 끝까지 달리기 때문입니다. 그러니 이 옵션이 실제로 하는 일은 “멈춰 서서 사람에게 묻지 않는다”뿐이며, 성공 여부의 정보는 사라지지 않습니다. 같은 문서를 latexmk -pdf에 통과시키면 이쪽은 0이 아닌 값으로 끝나고 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할 때마다 대신 그 질문을 던져 주니까요.