Platform EngineeringInfrastructureAutomation

Version-Controlled Resumes with Typst: PDF Generation via GitHub Actions

How to replace Word and Google Docs with a Typst source file that compiles to a professional PDF, auto-built by GitHub Actions with git SHA and date stamped on every page.

3 April 2026 · 8 min read

My resume lived in Google Docs for years. Every update meant exporting a PDF, renaming the file, uploading it somewhere, and hoping the version I sent to a recruiter was actually the latest one. No diff history, no automation, no way to know which version was “the one.”

I replaced all of that with a single Typst source file, a GitHub Action, and about 30 minutes of setup. Every push to main auto-generates a PDF with the git SHA and build date stamped in the footer. The PDF is committed back to the repo and served directly from my site.

Why Typst Over the Alternatives

I evaluated four approaches before settling on Typst:

ApproachProsCons
LaTeXGold standard for typesettingEnormous dependency tree, arcane syntax, slow compilation
HTML + PuppeteerReuse web skillsBrowser dependency, non-deterministic rendering, heavy in CI
Markdown + PandocSimple source formatLimited layout control, needs LaTeX anyway for good PDF output
TypstSimple syntax, fast compilation, single binary, deterministic outputNewer ecosystem, fewer templates

Typst wins on the combination that matters for a resume: simple syntax (reads like Markdown with layout control), fast compilation (~200ms), single binary (~30MB, trivial in CI), and deterministic output (same input always produces the same PDF).

Quick Typst Tutorial

If you’ve never used Typst, here’s what you need to know to get started.

Installation

# macOS
brew install typst

# Linux (download binary)
curl -fsSL https://github.com/typst/typst/releases/latest/download/typst-x86_64-unknown-linux-musl.tar.xz \
  | tar -xJ --strip-components=1 -C ~/.local/bin/

# Compile a document
typst compile resume.typ resume.pdf

# Watch mode (recompiles on save)
typst watch resume.typ resume.pdf

Syntax Basics

Typst has two modes: markup mode (like Markdown) and code mode (prefixed with #).

// This is a comment

// Markup mode — just write text
This is a paragraph with *bold* and _italic_ text.

// Headings
= Heading 1
== Heading 2

// Lists
- Bullet item
- Another item

// Code mode — prefix with #
#set text(font: "Helvetica", size: 11pt)
#set page(paper: "a4", margin: 1in)

// Variables
#let primary-color = rgb("#2a6496")

// Functions
#text(fill: primary-color, weight: "bold")[Important text]

Layout Primitives

These are the building blocks you’ll use most for a resume:

// Grid — the workhorse for multi-column layouts
#grid(
  columns: (1fr, auto),           // first column fills space, second auto-sizes
  [Job Title],                     // left cell
  [2020 – Present],               // right cell
)

// Table — grid with borders
#table(
  columns: (100pt, 1fr),
  inset: 6pt,
  stroke: 0.5pt + luma(200),
  [*Category*], [Details here],
  [*Another*],  [More details],
)

// Alignment
#align(center)[Centered content]

// Spacing
#v(8pt)   // vertical space
#h(1fr)   // horizontal fill (pushes content apart)

// Links
#link("https://example.com")[Click here]

Page Setup and Footers

#set page(
  paper: "a4",
  margin: (top: 0.6in, bottom: 0.6in, left: 0.7in, right: 0.7in),
  footer: context {
    let current = counter(page).get().first()
    let total = counter(page).final().first()
    set text(size: 8pt, fill: luma(150))
    align(center)[Page #current of #total]
  },
)

Passing Data at Compile Time

Typst supports --input flags to inject values at compile time — perfect for build metadata:

// In your .typ file — read inputs with fallback defaults
#let git-sha = sys.inputs.at("git-sha", default: "dev")
#let build-date = sys.inputs.at("build-date", default: "unknown")

// Use them anywhere
Last updated: #build-date · Git: #git-sha
# At compile time
typst compile \
  --input git-sha=$(git rev-parse --short HEAD) \
  --input build-date=$(date +%Y-%m-%d) \
  resume.typ resume.pdf

That’s enough to build a professional resume. The Typst documentation covers everything else, and the Typst Universe has templates if you want a starting point.

The Resume Source File

Here’s the structure of my resume.typ. I’m showing the scaffolding, not the full content — the patterns are what matter.

// ─── Theme colors ───
#let primary = rgb("#2a6496")
#let text-color = rgb("#333333")
#let light-gray = rgb("#666666")

// ─── Build metadata ───
#let git-sha = sys.inputs.at("git-sha", default: "dev")
#let build-date = sys.inputs.at("build-date", default: "unknown")

// ─── Page setup with version-stamped footer ───
#set page(
  paper: "a4",
  margin: (top: 0.6in, bottom: 0.6in, left: 0.7in, right: 0.7in),
  footer: context {
    let current = counter(page).get().first()
    let total = counter(page).final().first()
    set text(size: 8pt, fill: light-gray)
    grid(
      columns: (1fr, 1fr, 1fr),
      align(left)[Last updated: #build-date],
      align(center)[Name | Page #current of #total],
      align(right)[Git: #git-sha],
    )
  },
)

// ─── Reusable components ───
#let section-heading(title) = {
  v(6pt)
  text(size: 12pt, weight: "bold", fill: primary, upper(title))
  v(-2pt)
  line(length: 100%, stroke: 1.5pt + primary)
  v(2pt)
}

#let job-header(title, dates, company) = {
  v(4pt)
  grid(
    columns: (1fr, auto),
    text(size: 10.5pt, weight: "bold", title),
    text(size: 9.5pt, dates),
  )
  text(size: 9pt, style: "italic", fill: light-gray, company)
  v(2pt)
}

#let bullet(content) = {
  grid(
    columns: (12pt, 1fr),
    gutter: 2pt,
    text(""),
    content,
  )
}

The key patterns:

  • Reusable functions (section-heading, job-header, bullet) keep the content sections clean and consistent
  • Build metadata via sys.inputs — the footer shows the git SHA and date on every page without hardcoding
  • Color variables at the top make it easy to retheme the entire document

Each content section then reads naturally:

#section-heading("Professional Experience")

#job-header(
  "Platform Engineer – AI & Cloud Infrastructure",
  "2020 – Present",
  "Acme Corp — Melbourne, Australia",
)

#bullet[Built and operated petabyte-scale data platform on AWS.]
#bullet[Implemented fine-grained access controls using Lake Formation.]

The GitHub Action

The workflow triggers on changes to resume/resume.typ, compiles the PDF with git metadata, and commits it back to main:

name: Build Resume PDF

on:
  push:
    paths:
      - "resume/resume.typ"
    branches:
      - main
  workflow_dispatch:

permissions:
  contents: write

jobs:
  build-resume:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Typst
        uses: typst-community/setup-typst@v4

      - name: Compile PDF
        run: |
          typst compile \
            --input git-sha=$(git rev-parse --short HEAD) \
            --input build-date=$(date +%Y-%m-%d) \
            resume/resume.typ public/resume.pdf

      - name: Commit PDF to main
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add public/resume.pdf
          git diff --cached --quiet && echo "No changes" && exit 0
          git commit -m "[resume] Auto-generate PDF from resume.typ"
          git push

A few design decisions worth noting:

  • paths filter — the workflow only runs when resume.typ changes, not on every push
  • workflow_dispatch — allows manual triggering from the GitHub UI if needed
  • git diff --cached --quiet — skips the commit if the PDF didn’t actually change (idempotent)
  • Output goes to public/ — in my Astro site, everything in public/ is served as-is, so the PDF is immediately available at yoursite.com/resume.pdf

Local Development

For local iteration, I added a convenience script to package.json:

{
  "scripts": {
    "resume": "typst compile --input git-sha=$(git rev-parse --short HEAD) --input build-date=$(date +%Y-%m-%d) resume/resume.typ public/resume.pdf"
  }
}

And Typst’s watch mode is excellent for live preview:

typst watch \
  --input git-sha=$(git rev-parse --short HEAD) \
  --input build-date=$(date +%Y-%m-%d) \
  resume/resume.typ public/resume.pdf

Every save recompiles in ~200ms. Pair it with a PDF viewer that auto-reloads (most do) and you have a live preview loop.

What About HTML?

Typst has experimental HTML export (--features html --format html), but as of mid-2026 it drops grids, alignment, spacing, and page setup — everything that makes a resume look good. Not viable yet.

If you need an HTML resume page, build it separately in your site framework (Astro, Next.js, etc.) and keep the Typst file as the PDF source of truth. You could share data by extracting resume content into a JSON file that both the Typst template and your HTML page consume, but for most people the PDF is the deliverable and a simple /resume page linking to the PDF download is enough.

The Result

Every page of my resume now shows:

  • Left footer: Last updated: 2026-04-03
  • Center footer: Jagat Singh | Page 1 of 3
  • Right footer: Git: a1b2c3d

The workflow is: edit resume.typ → push → GitHub Action compiles and commits the PDF → Cloudflare Pages deploys → PDF is live at my site URL. Full git history of every resume change, diffable source, zero manual export steps.

The entire setup is three files: resume/resume.typ (source), .github/workflows/resume.yml (automation), and the generated PDF in public/. That’s it.