One YAML file in β a consistent LaTeX PDF and a matching web page out, built and published by GitHub Actions, with the source encrypted and the web page behind a code so a public repository can still hold a CV.
This repository is tooling: it contains no personal data. Your content lives in
data/cv.yaml, and the repository stores it encrypted (data/cv.yaml.age,
age) β so a fork starts empty, and nothing readable
is ever committed.
| You get | Built by | Published to |
|---|---|---|
cv.pdf (LaTeX / XeLaTeX) |
GitHub Actions | inside the page β it has no URL of its own |
web/index.html + web/style.css |
GitHub Actions | GitHub Pages, page encrypted with StatiCrypt |
Both renderings are produced from the same source, so the palette, fonts and
margins can never drift apart. One code (CV_PDF_PASSWORD) encrypts the web
page: StatiCrypt asks for it before showing anything, and the PDF travels
inside that page β it is the only copy that exists, so there is no /cv.pdf
URL to fetch and the PDF itself needs no password. See
Keeping it private.
Requirements
| To do this | You need |
|---|---|
| Edit content | Python 3 + python -m pip install pyyaml |
| Build the PDF locally | TeX Live / MiKTeX with xelatex + latexmk β or skip it: CI compiles |
| Encrypt the source | age: winget install FiloSottile.age Β· apt install age Β· brew install age |
| Run the pipeline | a GitHub repository with Actions + Pages (the free plan is enough) |
-
Fork / clone / βUse this templateβ, then:
python -m pip install pyyaml cp data/cv.example.yaml data/cv.yaml # PowerShell: Copy-Itemdata/cv.example.yamlis a complete, valid starter: the fields it shows are the required ones, everything else is optional. It passesscripts/validate.pyas-is. -
Replace your details. In
data/cv.yaml, search forYOUR-USER,YOUR-REPO,Your Name,Your Job Titleandyou@example.comβ that is every personal placeholder. Nothing inscripts/,templates/or the workflows is personal. -
Render and check:
python scripts/generate.py # data/cv.yaml -> src/generated/*.tex + web/index.html python scripts/validate.py # must print: ALL STATIC CHECKS PASSED
-
Decide how private you want it (details in Keeping it private):
- Encrypted source (default here) β
age-keygen -o ~/.age/cv-key.txt,make encrypt(Windows:.\scripts\crypt.ps1 encrypt), commitdata/cv.yaml.age. The cleardata/cv.yamlstays out of git (it is already in.gitignore). - Plain source β commit
data/cv.yaml(delete it from.gitignore) and remove the encryption steps from.github/workflows/build-cv.yml. The workflow is deliberately fail-closed, so this is a change you make on purpose, not by forgetting a secret. - Only the first option keeps the repository safe to be public.
- Encrypted source (default here) β
-
Turn on the CI in your repository:
- Settings β Secrets and variables β Actions: add
AGE_KEY(the content of your age identity file) andCV_PDF_PASSWORD(the code you hand out for the web page, 14+ characters). - Settings β Pages β Build and deployment β Source =
GitHub Actions. - Settings β Actions β General β Workflow permissions = Read and write (the defaults are fine too: nothing is published outside Pages).
- Settings β Secrets and variables β Actions: add
-
Push β the workflow builds and deploys:
git add -A && git commit -m "my CV" && git push
Your site is then at https://YOUR-USER.github.io/YOUR-REPO/: type the code and the
page shows, with the PDF downloadable from the two buttons in the toolbar (the PDF
lives inside that page β there is no separate PDF URL, no Release asset and no
workflow artifact to fetch). Optional badge for the top of your README:
[](https://github.com/YOUR-USER/YOUR-REPO/actions/workflows/build-cv.yml)# 1. make sure the clear file is on disk (skip if you still have it)
make decrypt # Windows: .\scripts\crypt.ps1 decrypt
# 2. edit, then render and check
notepad data/cv.yaml
python scripts/generate.py
python scripts/validate.py # also fails if the encrypted copy is out of date
# 3. re-encrypt and push (only the ciphertext is committed)
make encrypt # Windows: .\scripts\crypt.ps1 encrypt
git add -A && git commit -m "content: ..." && git pushOn that push CI: decrypts data/cv.yaml.age (AGE_KEY) β regenerates the LaTeX and
the HTML β compiles the PDF β embeds it in the page β encrypts the page with
StatiCrypt (CV_PDF_PASSWORD) β verifies that nothing readable survives β deploys
to Pages. Nothing else is published: no Release, no artifact, no cv.pdf URL.
scripts/validate.py guards the most likely mistake: it decrypts
data/cv.yaml.age with your local key and compares it with data/cv.yaml, so a
forgotten make encrypt is caught locally instead of silently publishing the
previous version.
.
ββ data/
β ββ cv.yaml β
your content, clear text (gitignored, never pushed)
β ββ cv.yaml.age β
the same content, encrypted β this is what the repo holds
β ββ cv.example.yaml a complete starter file to copy from
ββ src/
β ββ cv.tex entry point: section order (XeLaTeX)
β ββ cvstyle.tex look & feel of the PDF (fonts, macros, spacing)
β ββ generated/ β΅ built from data/cv.yaml, never committed
ββ templates/
β ββ index.html shell of the web page (placeholders only)
ββ web/
β ββ index.html β΅ built from data/cv.yaml, never committed
β ββ style.css look & feel of the web page (uses CSS variables)
β ββ preview.png local OG preview image (make preview, not published)
ββ scripts/
β ββ generate.py data/cv.yaml -> LaTeX + HTML
β ββ embed_pdf.py PDF -> base64 inside the page (no PDF URL)
β ββ validate.py static checks + "did you forget to encrypt?"
β ββ crypt.ps1 encrypt / decrypt the source (Windows)
β ββ protect_pdf.py optional: AES-256-encrypt a PDF for a hand-out
β ββ check_site.py fails the build if _site still has plain text
β ββ preview.py first PDF page -> web/preview.png
ββ .github/workflows/
β ββ build-cv.yml decrypt -> generate -> compile -> embed + encrypt page -> Pages
β ββ lint.yml decrypt -> generate + validate + spell check
ββ Makefile / build.ps1 local helpers (make encrypt / make decrypt)
ββ LICENSE
Not committed (all in .gitignore, they never reach GitHub): data/cv.yaml,
src/generated/, web/index.html, web/preview.png, dist/, _site/, any age
identity key, and local notes such as Agent.md or .secrets.
data/cv.yaml.age β what the repository carries (age, ChaCha20-Poly1305)
β age -d -i $AGE_KEY (CI) / make decrypt (you)
βΌ
data/cv.yaml β clear text: exists on the CI runner and on your machine only
β
scripts/generate.py
ββββΊ src/generated/*.tex ββ XeLaTeX βββΊ src/cv.pdf
ββββΊ web/index.html
β
scripts/validate.py β fails if a section is missing, if a value from
cv.yaml is not in BOTH renderings, or if the
encrypted copy is out of date
β
scripts/embed_pdf.py β src/cv.pdf becomes base64 *inside* the page,
so no /cv.pdf exists to fetch
β
StatiCrypt (CI) β encrypts that page with CV_PDF_PASSWORD
β
_site/ (one encrypted index.html + style.css)
ββββΊ GitHub Pages
β
scripts/check_site.py β fails if the page is not StatiCrypt-encrypted, if a
separate PDF turned up, or if any published file
carries the CV in the clear
The repository only ever carries ciphertext, and GitHub Pages serves exactly one encrypted page. The PDF is inside that page, so it is reachable only after the code is typed β while the PDF file itself carries no password and opens in any reader once it has been downloaded.
Everything is plain text β you never write LaTeX or HTML by hand; %, &, _,
quotes and dashes are escaped for you.
meta: # name, role, contact details, page/PDF titles, links β¦
theme: # the palette used by BOTH renderings
fonts: # latex: a font shipped with TeX Live | html: a Google-Fonts family
layout: # PDF margins + the maximum width of the web page
sections:
summary: { title: Profile Summary, text: "β¦" }
experience_projects: { title: Experience & Projects, entries: [ {name, role,
company, dates, description, technologies_tools?,
responsibilities[], achievements[]} ] }
skills: { title: Skills, groups: [ {name, items, level?} ] }
education: { title: Education, entries: [ {school, major, dates?} ] }
languages: { title: Languages, entries: [ {name, score} ] }
certificates: { title: Certifications, entries: [ {name, issuer, issued,
expires, credential_id?} ] }sections must contain exactly those six keys, and the order in which they are
rendered is fixed in SECTION_ORDER inside scripts/generate.py β not by the
order in the file. A section key is only an internal name (it becomes the file
name src/generated/<key>.tex and the HTML id); what a reader β and an ATS β
sees is the section title:, so name the title the way the job ads do
(Work Experience, Skills, Certifications, β¦).
An entry of experience_projects can describe a role, a project or both:
name / role / company / dates are required and description /
technologies_tools / responsibilities / achievements are optional, so an
entry is only as long as you need. data/cv.example.yaml shows every field, and
python scripts/validate.py lints the structure (unknown keys, missing keys,
duplicate keys, a # that YAML keeps as text, date ranges that overlap, β¦).
python scripts/test_validate.py proves that lint actually fires, by feeding it
14 deliberately broken copies of data/cv.yaml (schema keys only, so the test
file itself carries no CV content).
Common tasks:
- New role or project β add an entry under
sections.experience_projects.entries(newest first). - Achievements β the
achievements:list of that entry β the best place for numbers (βcut report latency by 40 %β). - New skill row β add to
sections.skills.groups(level: ""hides the level chip for one row). - Re-colour everything β change
theme.bar/theme.accent/ β¦ and runpython scripts/generate.py: the PDF and the web page pick it up. - Change the fonts β
fonts.latexmust be a package shipped with TeX Live,fonts.htmla Google-Fonts family (the stylesheet URL is generated from it). - Hide the site from search engines β
meta.noindex: true. - Rename the file the browser saves β
meta.pdf_download_name(e.g.Firstname_Lastname_CV.pdf). - A variant for another role β copy
data/cv.yaml(e.g.data/cv-backend.yaml) and change the one path inscripts/generate.py.
| Layer | File | Contains |
|---|---|---|
| Content | data/cv.yaml |
all text + palette + margins |
| PDF design | src/cv.tex + src/cvstyle.tex |
section order, fonts, macros (\cvsection, \cvxpentry, \cvdesc, \cvtech, β¦), spacing |
| Web design | templates/index.html + web/style.css |
page chrome, CSS grid/flex layout (colours and widths come from data/cv.yaml) |
Both renderings intentionally share one structure: centred header β contact line β
coloured section bars β entries with a right-aligned date β shaded βTechnologiesβ
strips. If you change a spacing value in src/cvstyle.tex, change the matching
value in web/style.css so the two keep matching.
make gen # data/cv.yaml -> src/generated/*.tex + web/index.html
make check # validate.py + generate.py --check
make pdf # gen + XeLaTeX -> src/cv.pdf + dist/cv.pdf
make site # + _site/ : the page with the PDF embedded (what CI encrypts)
make preview # gen + pdf + web/preview.png (needs: pip install pypdfium2)
make protected PASSCODE="the-code" # optional: dist/cv-protected.pdf
make encrypt # data/cv.yaml -> data/cv.yaml.age (commit this one)
make decrypt # data/cv.yaml.age -> data/cv.yaml
make cleanWindows equivalents:
.\build.ps1
.\build.ps1 -Site
.\build.ps1 -Preview
.\build.ps1 -Protected "the-code"
powershell -ExecutionPolicy Bypass -File .\scripts\crypt.ps1 encrypt
powershell -ExecutionPolicy Bypass -File .\scripts\crypt.ps1 decryptdata/cv.example.yaml, the Makefile and scripts/crypt.ps1 expect the age key at
~/.age/cv-key.txt (%USERPROFILE%\.age\cv-key.txt on Windows); override with
AGE_KEY_FILE / -KeyFile or the CV_AGE_KEY_FILE environment variable.
A GitHub Pages site cannot be made private on a free (or Pro) account β private Pages is a GitHub Enterprise Cloud feature β so this project protects the content rather than the page:
| Where | What sits there | Protected by |
|---|---|---|
| the repository | data/cv.yaml.age β your content, encrypted with age |
X25519 + ChaCha20-Poly1305; the key never enters git |
| the git history | the same ciphertext (the clear file and every rendering are gitignored) | nothing readable |
| GitHub Pages | one StatiCrypt-encrypted page that contains the PDF (base64) | the code β and the PDF has no URL of its own |
| Releases / artifacts | nothing β the workflow creates neither | β |
| CI logs | fingerprints (len=β¦ sha256=β¦), never your text |
by design β the logs of a public repository are world-readable |
The PDF has no URL. It is embedded in the page (scripts/embed_pdf.py) and
encrypted together with it, so a visitor must type CV_PDF_PASSWORD before the
download buttons work; releases/latest/download/cv.pdf and β¦/cv.pdf do not
exist. The PDF file itself carries no password: after the download it opens in any
reader, so recruiters never have to type anything twice.
That also means the file only reaches someone you gave the code (or the page) to. If you would rather hand out a revocable file β one code for one recruiter β encrypt a copy locally and send that instead:
make protected PASSCODE="the-code" # dist/cv-protected.pdf β asks for the codeThe identity key is the single point of failure. Keep a copy in a password manager: lose it and the committed ciphertext can never be decrypted; leak it and your content can be read. To rotate it:
age-keygen -o ~/.age/new-key.txt
make encrypt AGE_KEY_FILE=~/.age/new-key.txt # re-encrypt the source with the new key
# then update the AGE_KEY secret and re-run the workflowStill public: web/style.css (CSS only, no content) and the fact that the
repository exists. The encrypted page reveals nothing about you β not even its
title β until the code is typed, and the PDF stays sealed inside it. That code is
shared with everyone you hand it to, so rotate it per employer and never reuse a
password you use elsewhere. If you want a real gatekeeper instead of client-side
encryption, put the site behind Cloudflare Access (free for up to 50 users): it
checks who is asking before any content is served.
| Guard | What it catches |
|---|---|
scripts/validate.py β age drift check |
you edited data/cv.yaml and forgot to re-encrypt it (the committed .age no longer matches) |
scripts/validate.py β sync check |
a value from the YAML missing from the LaTeX or the HTML; \input targets that do not exist; unbalanced braces / \beginβ¦\end; a generated section src/cv.tex forgot to include; broken HTML tag nesting; leftover {{TEMPLATE}} tokens; invalid workflow YAML |
scripts/check_site.py |
anything about to be published that still contains the CV in clear text: the page must carry the StatiCrypt marker, no *.pdf file may sit next to it (the PDF is embedded in the page), and no published file may contain a cv.yaml string in its raw bytes |
| build guards (content branch) | a missing AGE_KEY or CV_PDF_PASSWORD: the build fails instead of publishing a readable page |
validate.py and check_site.py print fingerprints (len=β¦ sha256=β¦) instead
of the offending text, because the logs of a public repository are world-readable.
Run python scripts/check_site.py <dir> --show-text locally when you need the real
strings.
build-cv.ymlβ on pushes to the branches listed in the workflow and on manual runs (pull requests from forks are skipped: they cannot read repository secrets). A branch that carries no CV source skips the build with a warning, so a tooling-only branch (e.g. the public template) stays green: installageβ decryptdata/cv.yaml.age(AGE_KEY) βgenerate.pyβ XeLaTeX compile β embed the PDF in the page β build_site/β encrypt that page with StatiCrypt (CV_PDF_PASSWORD) βcheck_site.pyβ upload the Pages artifact β deploy. Nothing else is published: no Release, no artifact, nocv.pdfURL.lint.ymlβ decrypt + generate +validate.py, plus a non-blocking codespell run.- Releasing: there is nothing to release. The site is always the latest build,
and since no GitHub Release is created any more,
releases/latest/download/cv.pdfis gone β delete the Releases an older setup created (Releases β the release β Delete) so the old link stops resolving too.
One-time repository setup:
- Settings β Pages β Build and deployment β Source:
GitHub Actions.β οΈ Not optional. While the source is a branch, GitHub runs its own Jekyll build on every push and publishes the whole repository in clear text, racing with (and sometimes overwriting) the site this workflow deploys. For the same reason never add a second Pages workflow (e.g. the Jekyll starter workflow): both would deploy to the samegithub-pagesenvironment. - If the branch carrying your content is not your default branch, allow it to
deploy: Settings β Environments β
github-pagesβ Deployment branches and tags β add that branch (or choose All branches). Without this the build succeeds but the deploy job fails with βBranch β¦ is not allowed to deploy to github-pages due to environment protection rules.β - Settings β Actions β General β Workflow permissions: the default read permission is enough β nothing is published outside Pages any more.
- Settings β Secrets and variables β Actions:
AGE_KEY= the whole content of your age identity file (~/.age/cv-key.txt). The workflow extracts the key itself, so a CRLF or single-line paste is fine β what it cannot do is guess a key that is not there.CV_PDF_PASSWORD= the code you hand out for the web page, 14+ characters recommended: it is the only thing standing between a stranger and the page and the PDF inside it, so a short code can be brute-forced offline. (StatiCrypt warns below 14 characters; the build passes--shortand keeps going.)
- Delete any Releases older runs created (Settings β β¦ β Releases): they still
serve
releases/latest/download/cv.pdf, and this setup no longer wants a public PDF link. - Optional: keep Settings β Actions β General β Artifact and log retention short β every run recreates the Pages artifact anyway.
If a step fails, the guards print the reason as a check annotation (Actions β
the run β Annotations), so you rarely need the raw log: AGE_KEY is not set,
age could not decrypt data/cv.yaml.age, AGE_KEY does not contain an age identity, Refuse to publish an unprotected CV page, and so on.
The design is a classic CV layout: centred header, coloured section bars, a skills table, right-aligned dates. Pleasant to read, slightly less machine-friendly than a plain single-column document.
- β
Text is selectable, fonts are embedded, PDF metadata (
pdftitle,pdfauthor, keywords) is set, and the web page carries schema.orgPersonJSON-LD β most ATS/parsers handle it. β οΈ If an application form demands plain text, copy it from the web page or build a single-column variant (a few lines insrc/cvstyle.tex).β οΈ There is no public PDF URL any more (the file is inside the code-protected page), so a form cannot fetch it: unlock your page, download the PDF, upload that file. That is the point β the PDF is only reachable by someone who was given the code.- Tip: the name the browser saves the file as comes from
meta.pdf_download_name(e.g.Firstname_Lastname_CV.pdf), so set it once instead of renaming by hand.
Two parts, two sets of terms β full text in LICENSE:
| Part | Covers | Terms |
|---|---|---|
| A. Tooling | scripts/, .github/workflows/, Makefile, build.ps1, templates/, web/style.css |
MIT β copy, modify, reuse, including commercially |
| B. Personal CV content | data/cv.yaml / data/cv.yaml.age, the rendered cv.pdf and web/index.html, web/preview.png |
All rights reserved β do not reuse someone else's CV |
If you fork this repository: part A is what you came for β take it freely and
replace part B with your own name and notice. The repository ships only the
encrypted blob, so a fork contains nothing readable to begin with; delete
data/cv.yaml.age anyway and start from data/cv.example.yaml.