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.
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:
| Approach | Pros | Cons |
|---|---|---|
| LaTeX | Gold standard for typesetting | Enormous dependency tree, arcane syntax, slow compilation |
| HTML + Puppeteer | Reuse web skills | Browser dependency, non-deterministic rendering, heavy in CI |
| Markdown + Pandoc | Simple source format | Limited layout control, needs LaTeX anyway for good PDF output |
| Typst | Simple syntax, fast compilation, single binary, deterministic output | Newer 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:
pathsfilter — the workflow only runs whenresume.typchanges, not on every pushworkflow_dispatch— allows manual triggering from the GitHub UI if neededgit diff --cached --quiet— skips the commit if the PDF didn’t actually change (idempotent)- Output goes to
public/— in my Astro site, everything inpublic/is served as-is, so the PDF is immediately available atyoursite.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.