The image factory for Applied Epi products. It builds and publishes the Docker images that render the epiRhandbook, and it owns the scripts that assemble the book from them.
The live product line is 2.9: nine images, published to ghcr.io/appliedepi/aedockerpublic.
Each image is a package environment. Chapter content is never baked into one: the .qmd is
mounted at render time.
The content lives in a separate repository,
appliedepi/epirhandbook. It owns the .qmd files
in every language, and a manifest (docker-images.yml) that says which image renders which
chapter. This repository owns packages, images and the render scripts. Neither repository fetches
from the other at build time.
Everything the 2.9 line builds from sits in epirhandbook/2.9/. Its own
README covers how packages install, how one chapter renders, and how
the book is assembled. CHANGELOG.md is the historical record.
2.5, 2.6, 2.7 and 2.8 are gone from the working tree. They remain in git history: git log --diff-filter=D -- archive/ finds the commit that removed them, and git show <sha>^:archive/<path> reads any file back.
Two files, read together as one catalogue: images.yaml at the repository root holds rbase, and
epirhandbook/2.9/images.yaml holds the other eight images. Base edges cross the two files:
epirhandbook-common is FROM rbase. Both files are hand-maintained, and images.yaml's own
header comment carries the authoritative field rules.
Each record names the image (name), one line about it (description), and the tags to publish
(tags). It also names the image in this catalogue it is FROM (base, or null). The base
edge drives the cascade. All four are required. An image with no description publishes its base
image's description, so the planner rejects a record without one. Four fields need more than their
name:
| Field | Meaning |
|---|---|
dir |
This image's own files: where its Dockerfile lives, and its change-detection scope. |
context |
The docker build context, when it differs from dir. A group's Dockerfile sits in groups/<group>/ but COPYs shared files from epirhandbook/2.9/, so the context is the shared root while change detection stays per group. |
renders |
The .qmd files this image renders, relative to the handbook source root. A list, for a group image. Required for any image whose dir has a groups path segment. |
live |
true means a rebuild of base cascades to this image. false opts out of that automatic cascade only. A direct edit to the image's own dir still builds it. |
The validator ties a group's renders list to the group that dir and name identify, and
refuses a .qmd claimed by two images. A record cannot drift into describing another group, and a
chapter cannot be rendered twice.
Push to main only. There is no nightly build and no scheduled run.
.github/scripts/changed_images.py decides what to rebuild. For each catalogue image it reads the
org.opencontainers.image.revision OCI label of the currently published image. That is a
metadata-only docker buildx imagetools inspect, never a docker pull. It then diffs, since that
commit: the image's own dir, the shared build-context inputs, and the CI machinery
(.github/scripts/, .github/workflows/). Anything changed means rebuild. Never published, or no
readable label, also means rebuild, which is fail-closed.
An image with a base has a second check. build_image.sh stamps it with the
org.opencontainers.image.base.digest label: the digest of the exact base image it was built FROM.
changed_images.py compares that label with the digest that the base's tag points to now. A
missing label, an unreadable base digest or a different digest means rebuild. An image with
live: false skips this check, because a moved base is the cascade it opts out of.
Each image renders a smoke document before it is pushed. After docker build, in both modes,
build_image.sh renders epirhandbook/2.9/common/smoke.qmd with the image's own
build_one_chapter.sh. The container has no network and the R_PROFILE_USER that
build_all_chapters.sh sets. A failed render stops the script before any push, and verify mode
exits 1. An image without /usr/local/bin/build_one_chapter.sh gets no render, and the log names
it. Today that is rbase alone. A pass shows that R, knitr, ggplot2 and Quarto render together in
the image. It does not show that the image holds every package its chapters need.
Know this before you push:
- The diff runs from each image's published revision to the pushed commit. Several commits in one push produce one build of the final state.
- A change anywhere under
.github/scripts/or.github/workflows/rebuilds all nine images, because it lands in every image's own diff. That includes editing a test:test_plan.pylives under.github/scripts/. Batch CI changes rather than pushing them one at a time. - Resume is automatic. After a partial publish, rerun. The images that published are skipped.
An image that failed is rebuilt, because its own diff shows the change or its base digest label
names the old base. The revision label alone cannot show the second case: a change under the
base's
diris not in the dependent image's own diff. - The base check compares digests, not commits. A base rebuilt from the same commit still gets
a new digest, because its
createdlabel changes. A live image FROM that base that did not rebuild in the same run then rebuilds on the next run.
One source of truth per axis, and no package version is asserted anywhere.
- CRAN: a dated Posit Package Manager snapshot. The date
lives in exactly one place, the
rbaseimage tag.build_image.shmatches a trailing-YYYY-MM-DDon the first tag and passes it as--build-arg CRAN_SNAPSHOT_DATE. The rule is generic: a tag without a date suffix, such as a group's2.9, does not match, and no build argument is passed. - Bioconductor: the release paired with R, from
BiocManager::version(). Derived, never stored. - GitHub: the one thing a dated CRAN snapshot cannot pin.
epirhandbook/2.9/packages_github.jsonholds 7 packages with a commit SHA each. - Resolution:
pak_install_subset.Rrunspak::pkg_install(refs, dependencies = NA). That is hard dependencies only (Depends, Imports, LinkingTo), with Suggests deliberately excluded. There is no hand-computed dependency closure. pak resolves the tree against a snapshot that never moves, so the result is deterministic.
epirhandbook/2.9/README.md covers what each image installs and why every group image is a
superset of its chapters' package footprints.
Add a package to a chapter. Add the bare name, one per line, to that chapter's
packages_cran_<stem>.txt. Run python3 epirhandbook/2.9/generate_groups.py. The generator
rewrites that group's packages_cran.txt and monolith/packages_cran.txt. Commit all three
changed files and push. The chapter's group image and the monolith rebuild.
Add a chapter.
- Capture its package list. Render the chapter once with a knitr
documenthook that writessort(loadedNamespaces()). Drop the base R packages:base,compiler,datasets,grDevices,graphics,grid,methods,stats,tools,utils. Use one name per line, with no comments and no blank lines. Save it asepirhandbook/2.9/groups/<group>/packages_cran_<stem>.txt. That location is what assigns the chapter to the group. - Add
content/en/<stem>.qmdto that group'srenderslist inepirhandbook/2.9/images.yaml. That file is a shared build input, so an edit to it rebuilds all eight 2.9 images. - Run
python3 epirhandbook/2.9/generate_groups.pyand commit everything. Push, then watch all eight publish:epirhandbook-common, the six group images and the monolith. - In the handbook repository, add the chapter to every language's
content/<lang>/_quarto.yaml, and add its row todocker-images.yml, naming the group image.build_all_chapters.shfails a book whose chapter has no manifest row, and a language whose chapter list differs from the main language's.
gis, restored on 2026-09-02, is the worked example of steps 1 to 3.
Update the R version or the CRAN snapshot. Change the date in rbase's tag in images.yaml
(rbase:4.6.0-<YYYY-MM-DD>). Never write a date anywhere else. The tag is the single source of
truth, and the build derives the snapshot URL from it. This rebuilds rbase and cascades to
everything.
That tag is mutable, deliberately. It is overwritten in the registry on every rbase rebuild, and has been rewritten at least eight times. The date names the CRAN snapshot the image was built against. It is not a promise that the bytes are frozen. Reviewers read "date-pinned" as "immutable" and file it as a supply-chain defect; it was filed once already, as box F27 of appliedepi/epirhandbook#455, and declined. For byte-immutability in a particular build, pin the digest at the point of use.
Pin a GitHub package to a new commit. Edit its RemoteSha in
epirhandbook/2.9/packages_github.json. This rebuilds epirhandbook-common, the six group
images and the monolith.
All nine packages are public. Verified on 2026-09-08: the GitHub packages API reports
visibility: public for each, and reports no private container package in the appliedepi
organization. Nothing that consumes these images needs docker login ghcr.io.
Making a GHCR package public cannot be automated. There is no REST endpoint and no GraphQL
mutation for package visibility. It is done one package at a time in the web UI. Go to the
package page, then the gear icon, then Danger Zone, then Change visibility, then Public. Confirm
by typing the package name. In the appliedepi organization this needs an org admin. Making
a package public is irreversible.
So a new image name starts private the first time CI publishes it, and stays private until an admin does the step above. Adding a chapter to an existing group creates no new image, so it needs no visibility change. Adding a new group does.
The names of those nine packages are exactly the nine names of the catalogue, which 2.9 keeps
unchanged from 2.8. Every tag published up to 2026-09-08 is 2.8, apart from
4.6.0-2026-07-01 on rbase and one survivor:
epirhandbook-common:2.7, a distinct digest inside the epirhandbook-common package, dated
2026-07-24. Nothing builds or consumes that tag.
- apt packages are not individually version-pinned. The
ubuntubase is digest-pinned. Packages installed on top of it are not. Accepted. - Rendered figures are not byte-reproducible. Several chapters use unseeded RNG.
- A pin whose package declares
Remotes:can drift into a conflict. Until 2026-09-02epirhandbook-commonpinned babeldown, whose DESCRIPTION declaresRemotes: ropensci-review-tools/babelquarto. pak resolves that to the repository HEAD. Once babelquarto's HEAD moved past the pinned babelquarto SHA, the two refs conflicted andepirhandbook-commoncould not build (run 33626696019). Neither package was used at render time, so both pins were removed, withtinkr, which only babeldown needed.brio,fsandxml2, which the render scripts import and which those pins had supplied by accident, are now explicit incommon/packages_cran.txt. Before you add a pin, read the package'sRemotes:field. Before you remove one, check what the render scripts import. - A base tag moved out of band is followed, not checked. The build resolves the digest of a non-rebuilt base live from whatever its published tag currently points at. A manual retag or a force-push moves that digest, so every live image FROM the base rebuilds on the next run, FROM the moved tag. Nothing checks the moved base itself. This is an accepted trust boundary.
One section per catalogue image. Every tag, base and chapter stem below comes from the two
catalogue files. A stem is a renders entry without the content/en/ prefix and the .qmd
suffix. The six group images follow the parts of the book's navbar.
R 4.6.0 on a digest-pinned Ubuntu, with a dated CRAN snapshot and no R packages at all. It carries
the system libraries the packages need, including GDAL, GEOS, PROJ and a JDK for rJava. Tag
4.6.0-2026-07-01. It is FROM an external base, so it has no base in this catalogue, and it
renders nothing.
The shared package environment. It holds 59 CRAN and Bioconductor names, all 7 GitHub pins, and
the render scripts on PATH. The 59 are the names most chapters share, plus the ones the render
scripts import. Tag 2.9. Base rbase. It renders no chapter, so it declares no renders list.
The basics group, 8 chapters. Tag 2.9. Base epirhandbook-common. Renders index,
editorial_style, data_used, basics, transition_to_r, packages_suggested, r_projects
and importing.
The data-management group, 9 chapters. Tag 2.9. Base epirhandbook-common. Renders cleaning,
dates, characters_strings, factors, pivoting, grouping, joining_matching,
deduplication and iteration.
The analysis group, 11 chapters. Tag 2.9. Base epirhandbook-common. Renders
tables_descriptive, stat_tests, regression, missing_data, standardization,
moving_average, time_series, contact_tracing, survey_analysis, survival_analysis and
gis.
The data-viz group, 11 chapters. Tag 2.9. Base epirhandbook-common. Renders
tables_presentation, ggplot_basics, ggplot_tips, epicurves, age_pyramid, heatmaps,
diagrams, combination_analysis, transmission_chains, phylogenetic_trees and
interactive_plots.
The reports group, 4 chapters. Tag 2.9. Base epirhandbook-common. Renders rmarkdown,
reportfactory, flexdashboard and shiny_basics.
The miscellaneous group, 7 chapters. Tag 2.9. Base epirhandbook-common. Renders
writing_functions, directories, collaboration, errors, help, network_drives and
data_table.
Every package of all six groups in one image. Tag 2.9. Base epirhandbook-common. It renders
nothing in CI. It is the dev-container image for contributors, named in the handbook's
.devcontainer.json, and it can render any chapter.
Everything below is for whoever maintains the image lines. A contributor using the images does not need it.
Two different versions of the handbook content exist. Do not mix them.
| Content | Where | Role |
|---|---|---|
Sep-18-2024 (epiRhandbook_eng commit c3cbc76) |
live at https://www.epirhandbook.com/en/ |
The frozen baseline. This is what we reproduce. |
Jan-2025 drift (branch richard @ e121efa, and deploy-preview) |
published nowhere | Parked. A 52-chapter unpublished content update. Reviewed only at the very end to salvage anything useful. |
The reproduction target is a fresh crawl of the live site, not the html_outputs/ committed in
the repo. The committed output is stale, so it is not a valid reference.
- Live crawls on compute:
~/ae/live_crawl(English) and~/ae/live_crawl_ml/<lang>(7 languages). - Sep-18 render source on compute:
~/ae/render_sep18. - The regression bar is
epirhandbook/2.5/verify/manifest.tsv— per-page text similarity plus asha16content hash. Regenerate it withepirhandbook/2.5/verify/make_manifest.py.
The manifest means "same output" only when package versions match. It is the bar for Phase 2 and Phase 3. It is not the bar for Phase 5, where newer packages legitimately render differently.
rbase, notbase. The name leaves room for a separatepythonbaselater, and it matches the existingghcr.io/niphr/cs/rbase.rbase:4.3.2is fully self-owned.FROM ubuntu:jammy(digest-pinned) + R 4.3.2 from Posit r-builds, with no rocker. Control and consistency over lower maintenance.- openblas 0.3.20 is installed deliberately. It is the exact BLAS that rocker links. Matching it is why dropping rocker moved no computed numbers. A different BLAS would have shifted values across many chapters.
- pak is driven by the lock, and chooses nothing.
renv.lockstays the single source of truth. The installed version is always the pin; the ref form only changes how each package is fetched. - CRAN is
cloud.r-project.orgsource, not PPM. Phase 2 restores a lock whose pins span many dates, so no single PPM snapshot contains them all. Only cloud carries every archived version. GITHUB_PATis a BuildKit secret. Never--build-arg+ENV, which would bake the token into the image'sConfig.Envand leak it ondocker inspector push.
- pak's SAT solver versus R 4.4. A naive
pkg@versionref fails for 15 packages. pak evaluates the current release's R constraint even when an older version is pinned, and reports a spurious dependency conflict. The fix isurl::refs pointing straight at the CRAN Archive tarball, which bypasses the solver. renv never hits this, because renv does not solve — it just installs the pin. - pak install ordering.
dependencies = FALSEresolves cleanly but drops build-order edges, so a source package races its own build dependency (RcppRoll built before Rcpp).dependencies = NArestores order but re-activates the solver. The fix is a topological layer install: build the graph from the lock's ownRequirements, Kahn-sort into 15 layers, install each layer withdependencies = FALSE. - Bioconductor drift. The lock pins
ggtree3.10.0, but Bioc 3.18's live contrib directory now serves 3.10.1. Only the Bioc Archive still has 3.10.0. - pak leaves about 4 GB of build scratch in
/tmp. Delete it in the sameRUNlayer, or the image doubles in size (9.5 GB → 5.1 GB). - Docker tag races. Two builds tagging the same image name: last to finish wins, so a bad build can clobber a good one. Serialize builds that share a tag.
- Two render failures are not the image's fault.
plot_continuousnever callslibrary(tidyr), and it is an unused.qmd.gisfetches live OpenStreetMap tiles at render time, which aborts the whole book, so it is commented out of_quarto.ymlfor rendering. - Render into a writable copy.
render_book()deleteshtml_outputsfirst. - Linux needs the filename-case shim. Run
python3 fix_image_case.py <source>before rendering.
- Build on compute. bench has no Docker. Rsync the build context to
compute:~/ae/ehb_build, thendocker buildover SSH. - Verify the built image, not the Dockerfile. After every build, run
docker inspect <img> --format '{{.Config.Env}}'to confirm no token was baked in. A source-only review, codex included, does not catch a baked-in secret. - Execution model: opus orchestrates and writes the brief, sonnet implements, a fresh sonnet re-runs the objective check and returns raw evidence, opus makes the call.
- codex is the phase gate. A phase is done only on codex sign-off. codex attacks soundness ("what is not really pinned"), not the render, which is objective and already measured.
- The gate is per phase, not per build iteration — Claude owns the tight loop, and the codex quota is spent deliberately.