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 current product line is 2.8. Published to ghcr.io/appliedepi/aedockerpublic:
| Image | What it is |
|---|---|
rbase:4.6.0-2026-07-01 |
R 4.6.0 on a digest-pinned Ubuntu, with a dated CRAN snapshot. No R packages. Carries the system libraries the packages need, including GDAL, GEOS, PROJ and a JDK for rJava. |
epirhandbook-common:2.8 |
FROM rbase. The 56 CRAN/Bioc packages most chapters share, all 9 GitHub-pinned packages, and the render scripts. |
epirhandbook-<group>:2.8 |
FROM common. One image per part of the book's navbar: basics (8 chapters), data-management (9), analysis (11, gis among them), data-viz (11), reports (4), miscellaneous (7). Each installs the union of its chapters' package lists. |
epirhandbook-monolith:2.8 |
FROM common. Every package of all six groups. It renders nothing in CI. It is the dev-container image for contributors, named in the handbook's .devcontainer.json. |
9 images in total. All nine are public. Verified on 2026-09-02: an anonymous manifest pull of each returned 200. See Visibility for what that means for a new image.
2.5, 2.6 and 2.7 are historical. Their directories stay as a record and CI never builds them. 2.7 published 49 per-chapter images. Nothing consumes those any more, and they are not pullable without a login.
Edited by hand, and sources of truth:
images.yamlandepirhandbook/2.8/images.yaml, the catalog.epirhandbook/2.8/groups.yaml, which chapter belongs to which group image.epirhandbook/2.8/packages_github.json, the 9 GitHub-pinned packages.- Each chapter's
packages_cran.txt. The 49 chapters that had a 2.7 image keep theirs underepirhandbook/2.7/chapters/<stem>/. A chapter added since lives underepirhandbook/2.8/chapters/<stem>/. Today that isgisalone. A stem lives in one of the two directories, never both.
Generated, and never edited by hand:
epirhandbook/2.8/groups/<group>/packages_cran.txt, one per group.epirhandbook/2.8/monolith/packages_cran.txt, the union of the six.
python3 epirhandbook/2.8/generate_groups.py writes all seven from groups.yaml and the chapter
lists. Run it after any change to those inputs and commit its output. --check regenerates in
memory and fails on any difference. CI does not run the generator. A stale group list ships
as it is committed.
The per-chapter lists were derived once, from an instrumented render that recorded
loadedNamespaces() per chapter. That derivation is finished, and its generator is archived at
epirhandbook/2.7/archive/ as a record of method. Do not run it. It would overwrite hand-made
edits.
Two files, read together as one logical catalog: images.yaml at the repo root (rbase) and
epirhandbook/2.8/images.yaml (epirhandbook-common, the six groups and the monolith). Base
edges cross between them: epirhandbook-common is FROM rbase.
| Field | Meaning |
|---|---|
name |
Published as ghcr.io/appliedepi/aedockerpublic/<name>:<tag>. Must be lowercase, a Docker constraint. |
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 lives in groups/<group>/ but COPYs shared files from epirhandbook/2.8/, so the context is the shared root while change detection stays per group. |
tags |
Tags to publish. The first is used for the local docker build -t; all are pushed. |
base |
<name>:<tag> of another image in this catalog that this image is FROM, or null. This edge drives the cascade. |
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. The monolith renders nothing, so it lives in epirhandbook/2.8/monolith/, beside groups/, not inside it. |
live |
true = a rebuild of base cascades to this image. false opts out of that automatic cascade only; a direct edit to its 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 row cannot drift into describing another group, and a
chapter cannot be rendered twice.
Push to main only. No nightly build, no scheduled run.
.github/scripts/changed_images.py decides what to rebuild. For each catalog image it reads that
image's currently published org.opencontainers.image.revision OCI label, 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, means
rebuild (fail-closed).
Know this before you push:
- The diff runs from each image's published revision to the pushed commit, so several commits in one push produce one build of the final state.
- A change anywhere under
.github/scripts/or.github/workflows/rebuilds all 9 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: images that published carry the current commit in their label and are skipped; the ones that failed are rebuilt.
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; rbase's Dockerfile builds the snapshot URL from it. The rule is generic: a tag without a date suffix (a group's2.8) does not match and no build-arg 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.8/packages_github.jsonholds 9 packages with a commit SHA each.commoninstalls all 9, so every group inherits them and a transitively-pulled GitHub package resolves to its pinned commit instead of coming from CRAN. - Resolution:
pak_install_subset.Rrunspak::pkg_install(refs, dependencies = NA): hard dependencies only (Depends/Imports/LinkingTo), 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.
common carries what most chapters need. Each group image is FROM common and installs its
group's full list; pak skips what common already holds, so the image is a superset of every member
chapter's footprint by construction. Every group Dockerfile ends with a build-time invariant: every
package in its packages_cran.txt must load, or the build fails.
Add a package to a chapter. Add the bare name, one per line, to that chapter's
packages_cran.txt. Run python3 epirhandbook/2.8/generate_groups.py. Commit both. 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()), and drop the base R packages (base,compiler,datasets,grDevices,graphics,grid,methods,stats,tools,utils). One name per line, no comments, no blank lines. Save it asepirhandbook/2.8/chapters/<stem>/packages_cran.txt. - Add
<stem>to a group inepirhandbook/2.8/groups.yaml. - Add
chapters/<stem>.qmdto that group'srenderslist inepirhandbook/2.8/images.yaml. That file is a shared build input: editing it rebuilds every 2.8 image, common included. Read the last item under Known limitations before you do. - Run
python3 epirhandbook/2.8/generate_groups.pyand commit everything. Push, and watch the group image and the monolith publish. - In the handbook repository, add the chapter's row to
docker-images.yml, naming the group image.build_all_chapters.shfails a book whose_quarto.ymldeclares a chapter with no row.
gis, restored on 2026-09-02, is the worked example of every step except step 3, which waits
for the babelquarto pin bump described under Known limitations.
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; the build derives the snapshot URL from it. This rebuilds rbase and cascades to everything.
Pin a GitHub package to a new commit. Edit its RemoteSha in
epirhandbook/2.8/packages_github.json. Rebuilds common and cascades to the six groups and the
monolith.
The content lives in a separate repository,
appliedepi/epirhandbook, which owns the
.qmd files in every language and a manifest (docker-images.yml) saying which image renders which
chapter. This repository owns packages, images and the render scripts; that one owns content and
the choice of image. Neither fetches from the other at build time.
Chapter content is never baked into an image. The image is a package environment; the .qmd is
mounted at render time.
The handbook's CI pulls the group images anonymously. A contributor opens the handbook in a dev container on the monolith, which can render any chapter.
They live in epirhandbook/2.8/common/ and are installed onto PATH in epirhandbook-common, so
every group image inherits them. Defining them once, here, is what stops the two repositories
drifting apart.
| Script | Runs | Does |
|---|---|---|
build_one_chapter.sh |
inside a group image | Renders ONE .qmd. |
rewrite_lang_config.R |
inside a container | Rewrites _quarto.yml for one language. |
build_all_chapters.sh |
on the CI runner | Orchestrates across images, so it cannot run inside one. CI extracts it: docker run --rm <common> cat /usr/local/bin/build_all_chapters.sh > build_all.sh |
inject_language_links.R |
inside a container | Adds the language-switcher dropdown to the assembled site. |
Each was established by experiment. Breaking any of them produces a broken site in which every render still exits zero, so an exit code is not evidence here.
- Rewrite
_quarto.ymlfor a language before rendering that language. Renderingchapter.fr.qmdagainst the English config writes the page outsidehtml_outputs/, titled with the bare filename, markedlang="en", with no sidebar and a duplicated asset tree. - Render every chapter twice. A chapter rendered before its cross-reference target registers in
.quarto/xrefemits a dead same-page anchor instead of a link to the other chapter, and is never re-rendered. The second pass resolves them. - Renders must be sequential within a language, sharing one directory. That is what lets Quarto
accumulate the search index across separate container runs, and it is why there is no
merge_search.sh. Parallel renders would race onsearch.json. Different languages are independent and may run in parallel. - Inject the language switcher afterwards. Rendering never produces it.
Start each language from a pristine copy: rewrite_lang_config.R is not idempotent, and it moves
rather than copies the English source when a translation is missing. English assembles to the site
root, not to en/.
build_all_chapters.sh validates its own output rather than trusting exit codes: every expected page
exists, and the search index references each one. It also reports dead same-page fragments
without failing on them. The whole-book reference render of the real book contains 106 of its own,
which are pre-existing content bugs, so a gate there would fail every build forever.
CI renders inside a container that reaches only the registry. A chapter that fetches something
while it renders, such as map tiles, fails there. The GIS chapter is the precedent: it reads a
saved basemap from the handbook's data/gis/ and shows, without running, the code that fetched
it. Prove a new chapter the same way before you add it: render it inside
docker run --network none.
The nine 2.8 images are public. Verified on 2026-09-02 by an anonymous manifest GET of each,
which returned 200. Nothing that consumes them 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: package page,
gear icon, Danger Zone, Change visibility, Public, confirming by typing the package name. On 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 does not create a new image, so it needs no visibility change. Adding a new group does.
The 49 per-chapter 2.7 images were never made public. An anonymous pull of one returns 403. Nothing uses them since 2.8.
- 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.
commoncannot rebuild until its babelquarto pin moves.packages_github.jsonpins babeldown atc6ed926and babelquarto atba9a2a0. babeldown's DESCRIPTION declaresRemotes: ropensci-review-tools/babelquarto, which pak resolves to that repository's HEAD. On 2026-09-01 that HEAD moved to8329821, so pak now reports a conflict between the two babelquarto refs and the common build fails at the pak step. It failed exactly that way in run 33626696019 on 2026-09-02, when an edit toepirhandbook/2.8/images.yamlforced a rebuild. The publishedepirhandbook-common:2.8(revision7b82737) is unaffected. The fix is to bump the babelquarto pin to the current HEAD; both SHAs carry version 0.1.0.9000, and the render scripts vendor babelquarto's logic rather than call it. Until then, do not editimages.yaml,epirhandbook/2.8/images.yaml,pak_install_subset.R,packages_github.json,epirhandbook/2.8/common/or anything under.github/.- A base tag moved out of band is not detected. The build resolves a non-rebuilt base's digest live from whatever its published tag currently points at. That is correct only while the registry tag is written by this workflow alone; a manual retag or force-push would be followed silently. An accepted trust boundary, not a gap the build checks.