Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/cv.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ name: Build CV
# the example PDFs as artifacts.
#
# This workflow deliberately does NOT deploy or publish any PDF: deploy.yml builds
# cv/cv.tex alone and stages it for the Astro site. The site links one canonical
# CV; which PDFs it should offer is a separate question from whether they build.
# the same three documents, stages them for the Astro site and publishes them.
# The two workflows must therefore agree on the set — a document added here has
# to be added there, or it builds for review and never reaches a reader.

on:
push:
Expand All @@ -27,6 +28,7 @@ on:
- "package-lock.json"
- "web/**"
- ".github/workflows/cv.yml"
- ".github/workflows/deploy.yml"
pull_request:
paths:
- "cv/**"
Expand All @@ -42,6 +44,7 @@ on:
- "package-lock.json"
- "web/**"
- ".github/workflows/cv.yml"
- ".github/workflows/deploy.yml"
workflow_dispatch:

jobs:
Expand Down
42 changes: 30 additions & 12 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ name: Deploy site
# On a pull request everything runs except the publish step, so a PR proves the
# publishable site still builds. The CV workflow separately runs the synthetic
# adopter proof for both outputs in its pinned TeX environment.
#
# It builds and publishes all three printed documents. /cv/ links the full CV as
# the primary download and the two variants beside it.

on:
push:
Expand Down Expand Up @@ -59,27 +62,40 @@ jobs:
sudo apt-get update
sudo apt-get install --yes poppler-utils

# The printed CV is typeset from the committed generated form of the same
# content/cv.yaml the /cv/ page renders. It is never committed — no binary
# enters git history — so it is built here and dropped into web/public/
# before astro build picks it up. The texlive pin and the xelatex
# requirement are explained in cv.yml, which builds the same document on
# its own for review.
- name: Build the CV PDF 📄
# The printed CVs are typeset from the committed generated form of the same
# content/cv.yaml the /cv/ page renders. They are never committed — no
# binary enters git history — so they are built here and dropped into
# web/public/ before astro build picks them up. The texlive pin and the
# xelatex requirement are explained in cv.yml, which builds the same three
# documents on its own for review. Both workflows must build the same set:
# a variant that only cv.yml builds is a variant the site cannot offer.
- name: Build the CV PDFs 📄
uses: xu-cheng/latex-action@v3
with:
root_file: cv.tex
root_file: |
cv.tex
short.tex
teaching.tex
working_directory: cv
latexmk_use_xelatex: true
texlive_version: "2024"

- name: Check the printed CV baseline 🔎
run: bash scripts/check-cv-baseline.sh

- name: Stage the CV PDF 📄
- name: Check the printed CV baselines 🔎
run: |
bash scripts/check-cv-baseline.sh
bash scripts/check-cv-baseline.sh cv/short.pdf
bash scripts/check-cv-baseline.sh cv/teaching.pdf

# The published names are cv.pdf and cv-<variant>.pdf: cv.pdf keeps the
# address every existing link already uses, and the variants sort beside it
# under a name that still says "CV" once it is sitting in a stranger's
# downloads folder — which a bare short.pdf does not.
- name: Stage the CV PDFs 📄
run: |
mkdir -p web/public/assets
cp cv/cv.pdf web/public/assets/cv.pdf
cp cv/short.pdf web/public/assets/cv-short.pdf
cp cv/teaching.pdf web/public/assets/cv-teaching.pdf

- name: Install web dependencies 💾
run: npm ci
Expand Down Expand Up @@ -109,6 +125,8 @@ jobs:
run: |
test -f web/dist/.nojekyll
test -f web/dist/assets/cv.pdf
test -f web/dist/assets/cv-short.pdf
test -f web/dist/assets/cv-teaching.pdf
test -f web/dist/index.html

- name: Deploy 🚀
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ cv/*.toc
cv/*.xdv
cv/build/

# The CV PDF is built by the deploy workflow, never committed.
# The printed CVs are built by the deploy workflow, never committed.
web/public/assets/cv.pdf
web/public/assets/cv-short.pdf
web/public/assets/cv-teaching.pdf

# Staged from content/media before every astro build; content/ is the source.
web/public/media/
Expand Down
20 changes: 14 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ Publication and talk grouping is declared under
contradict. It walks every top-level list and never joins facts by matching prose.
- Counts, provenance, omissions, sorting views, announcements, and gaps are derived at build time.
Do not replace them with hand-written numbers or copy.
- A `\printbibliography` whose filter matches nothing prints nothing at all, its own heading
included. Under a hand-written `\section` that leaves a title over silence, so every hand-written
filtered block goes through `\cvbibfiltered` (`cv/preamble.tex`), which names the filter that
matched nothing instead of failing the build. The generated sections are exempt and must stay so.
- A zero-entry bibliography must skip its entire `refsection`; biber otherwise silently emits `[0]`
labels. `\cvdeclare` and `\cvdeclarebib` keep missing sections safe for a minimal record, and
`scripts/build-cv-data.test.mjs` proves every document names only macros they define.
Expand All @@ -112,12 +116,16 @@ and the bundled `LedgerSerif` faces. Keep those names distinct and intentional.

## Delivery

`.github/workflows/deploy.yml` builds `cv/cv.tex` alone, runs its baseline, stages that PDF, builds
the website, and publishes the generated distribution to `gh-pages`: the site links one canonical CV,
and whether it should offer the variants is unanswered. `.github/workflows/cv.yml` builds all three,
checks each baseline, exposes them as review artifacts, and runs the whole adopter cold start inside
its pinned TeX environment. The deploy workflow has no path filters because almost any tracked file
can affect a build.
`.github/workflows/deploy.yml` builds all three printed documents, runs each baseline, stages them
into the website's ignored assets directory as `cv.pdf` and `cv-<variant>.pdf`, builds the website,
and publishes the generated distribution to `gh-pages`. `/cv/` links the full CV as the primary
download and the variants beside it, each offered only when its file is really staged; `.gitignore`
lists the three staged names.
`.github/workflows/cv.yml` builds the same three for review, checks each baseline, exposes them as
artifacts, and runs the whole adopter cold start inside its pinned TeX environment. The two must
agree on the set — a document only one of them builds either never reaches a reader or reaches one
unreviewed — and `scripts/build-cv-data.test.mjs` fails when they disagree. The deploy workflow has
no path filters because almost any tracked file can affect a build.

## Maintaining this file

Expand Down
33 changes: 21 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,8 @@ affected baselines and explain why in `data/cv-baseline/README.md`.

## Deploy on GitHub Pages

The deploy workflow builds and validates both outputs, stages the fresh PDF into the site, and
publishes `web/dist/` to the `gh-pages` branch.
The deploy workflow builds and validates the website and all three printed documents, stages the
fresh PDFs into the site, and publishes `web/dist/` to the `gh-pages` branch.

The branch must exist before Pages can be pointed at it, so publish first and configure second:

Expand Down Expand Up @@ -283,11 +283,15 @@ merge you resolve rather than a conflict with your record.

Three documents ship, all built from the one record:

| File | Builds to | What it is |
| ----------------- | ----------------- | ---------------------------------------------------------------- |
| `cv/cv.tex` | `cv/cv.pdf` | the full CV, and the one the website links |
| `cv/short.tex` | `cv/short.pdf` | one page: appointments, education, awards, selected publications |
| `cv/teaching.tex` | `cv/teaching.pdf` | teaching and supervision first, appointments truncated |
| File | Builds to | Published as | What it is |
| ----------------- | ----------------- | ------------------------- | --------------------------------------------------------- |
| `cv/cv.tex` | `cv/cv.pdf` | `/assets/cv.pdf` | the full CV, and the primary download on `/cv/` |
| `cv/short.tex` | `cv/short.pdf` | `/assets/cv-short.pdf` | appointments, education, awards and selected publications |
| `cv/teaching.tex` | `cv/teaching.pdf` | `/assets/cv-teaching.pdf` | teaching and supervision first, appointments truncated |

The deploy workflow builds and publishes all three; `/cv/` links the full CV first and the two
variants beside it. The published names keep `cv.pdf` where every existing link already points and
prefix each variant with `cv-`, so a downloaded file still says what it is once it has left the site.

```sh
latexmk -xelatex -cd cv/short.tex
Expand All @@ -310,7 +314,11 @@ the contact block are written once and shared. A variant differs only in layout,
`content/cv.yaml`.
- **Print a narrower slice of the bibliography** with a `\defbibfilter` in the variant's own preamble,
matching a keyword you set on the entries in `content/publications.bib`. `cv/short.tex` filters on
`selected` this way.
`selected` this way, and prints it with `\cvbibfiltered{<heading>}{<filter>}` rather than
`\printbibliography`. Use the same macro in your own variant: biblatex prints _nothing_ for a filter
that matches nothing, its heading included, so a raw `\printbibliography` under your own `\section`
would leave a title with silence beneath it. `\cvbibfiltered` prints one line naming the filter
instead — nothing yet marked `selected` is a valid record, not a broken build.

That last one is deliberate and worth knowing: do **not** add a "Selected publications" section under
`publications:` in `content/cv.yaml` to get it. That list is one ordering shared by the full CV and the
Expand All @@ -329,8 +337,11 @@ For prose a variant needs and the full CV does not — a teaching statement in p
focus — add a top-level section with a `note:` and no entries; see `teaching_statement:` in
`content/cv.yaml`. `profile:` itself is a fixed set of fields.

Add a variant to CI by adding its file to `root_file:` in `.github/workflows/cv.yml` and recording a
baseline for it; `data/cv-baseline/README.md` has the two commands.
Add a variant to CI by adding its file to `root_file:` in **both** `.github/workflows/cv.yml` and
`.github/workflows/deploy.yml`, staging it in the deploy workflow, adding its conditional link in
`web/src/pages/cv.astro`, and recording a baseline for it; `data/cv-baseline/README.md` has the two
commands. The two workflows must name the same set — one builds for review, the other is the only
one that publishes — and `npm test` fails when they disagree.

## Take later ledgerpress improvements

Expand Down Expand Up @@ -455,8 +466,6 @@ The important contracts are kept executable:
`content/README.md` to an assistant, then reviewing every resulting fact. That is not a supported
import route yet: a generative step sits between source facts and a public claim, so ledgerpress
ships no official prompt or verification checklist and promises no automatic accuracy.
- A variant that prints a filtered bibliography has no count of what its filter matched, only of what
the `.bib` holds, so it prints its heading over nothing when the filter matches no entry.

## Licence

Expand Down
2 changes: 1 addition & 1 deletion content/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,7 @@ CI runs the same cold-start proof with a synthetic adopter on every push and pul
```bash
npm run build:cv-data # content/cv.yaml -> cv/generated/cv-data.tex
latexmk -xelatex -cd cv/cv.tex # the PDF. xelatex, not pdflatex
latexmk -xelatex -cd cv/short.tex # the same record, one page
latexmk -xelatex -cd cv/short.tex # the same record, compactly curated
latexmk -xelatex -cd cv/teaching.tex # the same record, led by teaching
npm run dev # the site
```
Expand Down
31 changes: 31 additions & 0 deletions cv/preamble.tex
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,37 @@
\vspace{0.8mm}\textbf{#1}\par\vspace{-0.3mm}
}

% A \printbibliography whose filter matches nothing prints NOTHING - biblatex
% skips the block, its own heading included. Where the record's own sections are
% printed that is the wanted behaviour and needs no help: cv.tex prints one
% \section over \cvPublicationsSections, so a section that matches nothing simply
% leaves no trace. It is wrong wherever a document writes its OWN \section above
% a single filtered block, as cv/short.tex does: the adopter gets a title with
% silence underneath and nothing to diagnose it by.
%
% \cvbibfiltered prints such a block and, when it matched nothing, says so and
% names the filter. It does not fail the build: an adopter who has simply not
% marked any work yet has a valid record, and a variant they never asked for must
% not stop their CV from building. The line names the filter and the document
% that defined it, which is the pair to grep for - cv/short.tex defines
% `shortselected` beside the comment explaining which keyword fills it.
%
% The count comes from \AtEveryBibitem, which steps a global counter as each
% entry is typeset, so it is already correct on the line after the block. Nothing
% is typeset by the step itself: a document with matches is byte-identical.
\newcounter{cvbibmatched}
\AtEveryBibitem{\stepcounter{cvbibmatched}}
\newcommand{\cvbibfiltered}[2]{%
\setcounter{cvbibmatched}{0}%
\printbibliography[heading=bibsubheading, title={#1}, filter=#2, resetnumbers=true]%
\ifnum\value{cvbibmatched}=0
\vspace{0.8mm}\textbf{#1}\par\vspace{-0.3mm}
{\footnotesize Nothing in the bibliography matches the \texttt{#2} filter,
so this section is empty. That filter is defined in \texttt{cv/\jobname.tex},
which explains what it selects.\par}
\fi
}

% No filters here. Which entry type belongs under which heading is a curated
% opinion, so it is declared in `content/cv.yaml` under `publications:` and
% `talks:` and read by the website too. build-cv-data.mjs turns each declared
Expand Down
10 changes: 6 additions & 4 deletions cv/short.tex
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
% -----------------------------------------------------------------------------
% THE SHORT CV: the full record cut to one page.
% THE SHORT CV: a compact cut of the full record.
%
% This file is a VARIANT. It repeats no fact: every line below either shares
% cv/preamble.tex and cv/header.tex with the full CV, or names a generated macro
Expand Down Expand Up @@ -71,16 +71,18 @@ \section{\textbf{Research Focus}}

% \cvPublicationsCount is how many entries content/publications.bib holds, not
% how many the filter matches, so this guards against an empty file rather than
% against an empty selection. A record with publications but nothing marked
% `selected` prints this heading over nothing.
% against an empty selection: a record with publications but nothing marked
% `selected` still reaches the heading below. \cvbibfiltered is what keeps that
% case honest - it prints a line naming the filter instead of leaving the
% heading over silence. See cv/preamble.tex.
\cvdeclarebib{Publications}
\ifnum\cvPublicationsCount>0
\section{\textbf{Selected Publications}}

\begin{refsection}[../content/publications.bib]
\nocite{*}

\printbibliography[heading=bibsubheading, title={Peer-reviewed}, filter=shortselected, resetnumbers=true]
\cvbibfiltered{Peer-reviewed}{shortselected}

\end{refsection}
\fi
Expand Down
57 changes: 57 additions & 0 deletions scripts/build-cv-data.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -545,3 +545,60 @@ for (const document of ["cv.tex", "short.tex", "teaching.tex"]) {
assert.deepEqual([...new Set(referenced.filter((name) => !defined.has(name)))], []);
});
}

// The two workflows must build the same documents. .github/workflows/cv.yml
// builds every printed document for review; deploy.yml builds them again and is
// the only one that publishes. A document added to one and not the other either
// never reaches a reader, or reaches one unreviewed - and the /cv/ page links
// what deploy.yml stages, so a missing stage is a 404 on a live site.
test("both workflows build every printed document, and the deploy publishes each one", () => {
const workflow = (name) => readFileSync(join(root, ".github/workflows", name), "utf8");
const built = (text) => [...text.matchAll(/^\s*(\w+)\.tex$/gm)].map((m) => m[1]).sort();
const deploy = workflow("deploy.yml");

const documents = ["cv", "short", "teaching"];
assert.deepEqual([...new Set(built(workflow("cv.yml")))], documents, "cv.yml must build every printed document");
assert.deepEqual([...new Set(built(deploy))], documents, "deploy.yml must build the same set cv.yml does");

// The published names: cv.pdf keeps the address every existing link uses, and
// each variant is staged as cv-<variant>.pdf beside it.
for (const document of documents) {
const asset = document === "cv" ? "cv.pdf" : `cv-${document}.pdf`;
assert.ok(deploy.includes(`cp cv/${document}.pdf web/public/assets/${asset}`), `deploy.yml must stage ${document}.pdf as ${asset}`);
assert.ok(deploy.includes(`test -f web/dist/assets/${asset}`), `deploy.yml must prove ${asset} reaches the published distribution`);
}
});

// A filtered bibliography that matches nothing prints nothing at all - biblatex
// skips the block, its own heading included. Under a hand-written \section that
// leaves a title with silence beneath it, which an adopter cannot diagnose: the
// document is not broken, no entry simply carries the keyword yet. Every
// hand-written filtered block therefore goes through \cvbibfiltered, which says
// so and names the filter. The generated sections in cv/generated/cv-data.tex
// are exempt and must stay so: cv.tex prints one heading over the whole
// sequence, so a section that matches nothing correctly leaves no trace.
for (const document of ["cv.tex", "short.tex", "teaching.tex"]) {
test(`cv/${document} announces a filtered bibliography that matched nothing`, () => {
const tex = layout(document);
assert.doesNotMatch(
tex,
/\\printbibliography\[[^\]]*filter=/,
`${document} must print a filtered bibliography through \\cvbibfiltered, or a filter that matches nothing leaves its heading over silence`
);
});
}

test("\\cvbibfiltered prints its own heading and a named placeholder when nothing matched", () => {
const tex = layout("preamble.tex");
const macro = tex.match(/\\newcommand\{\\cvbibfiltered\}\[2\]\{([\s\S]*?)\n\}/);
assert.ok(macro, "cv/preamble.tex must define \\cvbibfiltered");
assert.match(macro[1], /\\setcounter\{cvbibmatched\}\{0\}/, "the count must be reset before the block, not carried over from an earlier one");
assert.match(macro[1], /\\ifnum\\value\{cvbibmatched\}=0/, "the placeholder must be guarded on nothing having matched");
assert.match(macro[1], /#1/, "the placeholder must repeat the heading biblatex skipped");
assert.match(macro[1], /#2/, "the placeholder must name the filter, which is the word to grep for");
assert.match(
tex,
/\\AtEveryBibitem\{\\stepcounter\{cvbibmatched\}\}/,
"nothing increments the count without this hook, so every section would report itself empty"
);
});
5 changes: 3 additions & 2 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,6 @@ consistency gate after a production build. Development never refuses a contradic
record shows it while the author works. The production build fails before anything can be
published.

The deploy workflow builds the PDF first and stages it at `public/assets/cv.pdf`; a plain local site
build omits the download rather than offering a stale file.
The deploy workflow builds the printed CVs first and stages them at `public/assets/cv.pdf`,
`cv-short.pdf` and `cv-teaching.pdf`; a plain local site build omits each download rather than
offering a stale file, and `/cv/` offers only the ones really staged.
Loading
Loading