Skip to content

Renv and dependencies

Vini Salazar edited this page Aug 4, 2026 · 6 revisions

Workbench lessons keep two completely separate sets of R packages, and almost every confusing error people hit comes from accidentally merging them. This page explains the split, gives the one-time machine setup, and covers running Python inside an R Markdown lesson.

The one rule

The build toolchain is global. The lesson dependencies are managed by sandpaper. You never activate renv yourself.

Lives in Contains Installed by
Build toolchain your normal user library (.libPaths()[1], e.g. ~/Library/R/arm64/4.5/library) sandpaper, pegboard, tinkr, varnish you, once, with pak
Lesson dependencies renv/profiles/lesson-requirements/renv/library/ inside the lesson repo only the packages the .Rmd episodes actually library() sandpaper::manage_deps(), automatically

sandpaper deliberately does not make itself the project's renv environment. It uses a named renv profile called lesson-requirements, and it activates that profile in subprocesses, not in your session. serve() and build_lesson() spawn a callr subprocess for manage_deps() and one more per episode for knitting, each with RENV_PROFILE=lesson-requirements set, so .libPaths() inside those subprocesses is the lesson library. Your console keeps your normal library throughout, which is why sandpaper::serve() can find sandpaper at all.

So renv is in use during a build even though nothing in your session looks activated. If a build complains about missing packages, that is the episode subprocess reporting on the lesson library, not evidence that renv was skipped. See the build says packages are missing below.

If you run renv::init() or renv::activate() in a lesson repo you break this. Both commands write a .Rprofile at the repo root containing source("renv/activate.R"), which activates the lesson-requirements profile in every R session started in that directory. .libPaths() collapses to the lesson library, and sandpaper, pegboard and varnish appear to vanish. Do not run them.

What is in renv/, and what gets committed

renv/
├── activate.R                                  COMMIT   renv bootstrap, sandpaper calls it
├── profile                                     COMMIT   one line: "lesson-requirements"
├── .gitignore                                  COMMIT   keeps local scratch out of git
└── profiles/
    └── lesson-requirements/
        ├── renv.lock                           COMMIT   the pinned dependency set
        └── renv/
            ├── settings.json                   COMMIT
            ├── settings.dcf                    COMMIT
            ├── .gitignore                      COMMIT
            └── library/                        ignored, rebuilt on demand

renv.lock is the one that matters. CI decides whether a lesson has pinned dependencies purely by whether that file exists in the repository. If it is not committed, the lesson builds against whatever is current on the day, which is how a lesson silently stops reproducing.

A Markdown-only lesson (no .Rmd episodes, nothing executes) has no lesson dependencies and needs none of this. In those repos renv/ is gitignored on purpose.

Note what is not in that tree: a .Rprofile at the repository root. Compare against a reference Carpentries R lesson such as swcarpentry/r-novice-inflammation, which tracks renv/activate.R, renv/profile and renv/profiles/ and has no root .Rprofile. If one appears in your lesson, delete it. See below for the one workflow that creates it by accident.

One-time setup on your machine

We use pak rather than devtools. devtools has been split up and superseded, and pak resolves system requirements and GitHub sources without a separate install_github() call.

install.packages("pak")

options(repos = c(
  carpentries = "https://carpentries.r-universe.dev",
  CRAN        = "https://cloud.r-project.org"
))

pak::pak(c("sandpaper", "pegboard", "tinkr"))
pak::pak("melbournebioinformatics/uom-varnish")   # installs under the package name `varnish`

uom-varnish is our fork of the Carpentries varnish theme. It keeps the upstream package name, so it replaces varnish in your library rather than sitting alongside it. Every MB lesson pins it in config.yaml:

varnish: 'melbournebioinformatics/uom-varnish@main'
url: 'melbournebioinformatics.github.io/uom-varnish'

Then grant renv cache consent once, which sandpaper asks for interactively the first time otherwise:

sandpaper::use_package_cache(prompt = FALSE)

You also need pandoc and git on your PATH. Conda or miniforge is the easiest route on macOS.

Day-to-day

sandpaper::serve()          # live-reload preview on http://127.0.0.1:4321
sandpaper::build_lesson()   # one-off build into site/
sandpaper::check_lesson()   # pegboard structure and link validation

serve() and build_lesson() both call sandpaper::manage_deps() first, which:

  1. creates the lesson-requirements profile if it is missing,
  2. hydrates it from your user library, so packages you already have are linked rather than re-downloaded,
  3. downloads whatever is still missing,
  4. restores anything pinned in renv.lock,
  5. snapshots the result back to renv.lock.

Adding a package to an episode: install it into your user library the way you normally would, write library(thepackage) in the .Rmd, rebuild, then commit the changed renv.lock. Do not touch renv directly.

The install-it-first part matters. manage_deps() snapshots what is already installed; it will not go and fetch a package it has never seen. If the package is missing it says

The following required packages are not installed:
- clusterProfiler
Packages must first be installed before renv can snapshot them.

and quietly leaves it out of the lockfile, at which point CI has nothing to install and the episode fails to knit. Install first:

pak::pak("thepackage")                       # CRAN or GitHub
BiocManager::install("clusterProfiler")      # Bioconductor

For a Bioconductor lesson, make sure the lockfile carries a "Bioconductor": {"Version": "..."} key so renv resolves against the right release. It is written on the first snapshot taken with BiocManager available.

Useful maintenance calls:

sandpaper::manage_deps()                  # force the dependency pass on its own
sandpaper::update_cache()                 # update lesson packages, prompts per package
sandpaper::pin_version("ggplot2@3.5.1")   # hold a package at a version
sandpaper::no_package_cache()             # opt out of renv for this lesson entirely
options(sandpaper.use_renv = FALSE)       # opt out for this session only

renv::restore(), renv::init(), renv::activate() and renv::snapshot() are not part of this workflow. The only renv function worth knowing is renv::repair(), below.

Troubleshooting

could not find function "serve", or sandpaper/pegboard/varnish suddenly missing. There is a .Rprofile at the repo root. Delete it and restart R:

renv::deactivate(clean = TRUE)   # or simply: file.remove(".Rprofile")

Then check .libPaths() points at your user library again.

renv 1.1.5 was loaded from project library, but this project is configured to use renv 1.0.11. Same cause: the project got activated. Same fix.

The following package(s) have broken symlinks into the cache. The global renv cache moved or was cleaned. Either renv::repair(), or delete renv/profiles/lesson-requirements/renv/library and let the next manage_deps() rebuild it.

The project is out-of-sync -- use renv::status() for details. Only meaningful if you activated the project. Deactivate, then rebuild the lesson normally.

The build says packages are missing

Three different failures look alike. Match the message:

Packages must first be installed before renv can snapshot them, followed by a list. manage_deps() only snapshots packages that already exist; it will not fetch one it has never seen. They are silently left out of the lockfile, so the profile library never gets them and the episode fails to knit. Install them into your user library first, then rebuild:

pak::pak("thepackage")
BiocManager::install("clusterProfiler")

there is no package called 'X' during knitting. Same cause, one step later: the episode subprocess is running against the lesson library and X is not in it. Fix it the same way.

aborting snapshot due to pre-flight validation failure, listing packages as "required but not installed". Typically cachem, evaluate, fastmap, fontawesome, jquerylib, memoise, mime and sass. Nothing is wrong with the lesson. Those packages exist only in your R system library, so renv::restore() sees them on .libPaths() and skips installing them, while renv::snapshot() validates against the project library alone and reports them missing. The build gets stuck between the two.

Tell renv the system library is a legitimate external source. This is exactly what the CI container does, via the same variable:

Sys.setenv(RENV_CONFIG_EXTERNAL_LIBRARIES = .Library)
sandpaper::build_lesson()

On macOS .Library is /Library/Frameworks/R.framework/Versions/<ver>-arm64/Resources/library. To make it permanent, put that Sys.setenv() line in your user-level ~/.Rprofile. That file is fine and normal. The one you must never create is a .Rprofile inside a lesson repository.

The lockfile records a different R version than the one you are running. For a CRAN-only lesson this is a warning, not an error. renv proceeds and the version is re-recorded on the next snapshot. It happens whenever a lesson was last built by someone on a different R release.

For a Bioconductor lesson it is fatal, and the message you get in CI does not mention R at all. See below.

undefined symbol: PREXPR (or another missing symbol) when a Bioconductor package loads. The full error looks like this, and is followed by forty-odd dependency failed lines:

package or namespace load failed for 'S4Vectors' in dyn.load(file, DLLpath = DLLpath, ...):
  .../S4Vectors.so: undefined symbol: PREXPR

The lockfile pins a Bioconductor release that belongs to an older R. Each Bioconductor release is tied to one R version, and its C code is compiled against that R's API. PREXPR was withdrawn from R's public C API in 4.6, so anything built for Bioconductor 3.21 or 3.22 fails to load under R 4.6. The package that breaks is usually S4Vectors, because almost the entire Bioconductor graph sits on it, which is why one failure cascades into dozens.

The pairing, from https://bioconductor.org/config.yaml:

Bioconductor R
3.20 4.4
3.21, 3.22 4.5
3.23, 3.24 4.6

This is easy to cause by accident. CI builds inside rocker/rstudio:latest, so it always tracks the current R release. If your machine is a release behind, sandpaper::manage_deps() snapshots a lockfile that only works on your machine, and CI breaks the moment you commit it.

Fix it from CI rather than locally, since the runner has the R you are targeting. Run 02 Maintain: Check for Updated Packages with force-renv-init: true. That makes the underlying vise::ci_update() call renv::init(bioconductor = TRUE), which re-resolves the Bioconductor release against the runner's R and rebuilds the lockfile from scratch. Merge the PR it opens, then run 03, then 01. Expect it to take 30-45 minutes: every Bioconductor package compiles from source.

The durable fix is to keep your local R on the current release so your snapshots match CI.

'R_NamespaceRegistry' undeclared or was not declared in this scope. The same R 4.6 tightening, but on ordinary CRAN packages rather than Bioconductor. Seen on vctrs, Rcpp and S7, and it will appear on any pin predating the fix. The lockfile is simply too old.

Bump every stale CRAN record in one pass. Doing them one at a time is whack-a-mole, since each fixed package just exposes the next:

lf   <- "renv/profiles/lesson-requirements/renv.lock"
lock <- renv::lockfile_read(lf)
db   <- available.packages(repos = "https://cloud.r-project.org")
for (nm in names(lock$Packages)) {
  p <- lock$Packages[[nm]]
  if (!identical(p$Source, "Repository")) next
  if (nm %in% rownames(db) &&
      package_version(db[nm, "Version"]) > package_version(p$Version)) {
    p$Version <- unname(db[nm, "Version"])
    p$Hash <- NULL                    # a stale hash would miss the cache
    lock$Packages[[nm]] <- p
  }
}
renv::lockfile_write(lock, file = lf)

CRAN source versions do not depend on which R you are running, so this is safe on an older local R. Commit the lockfile, run 03, then 01.

A green workflow 02 does not mean the lockfile builds. 02 runs on a bare ubuntu-22.04 runner with RENV_CONFIG_REPOS_OVERRIDE pointing at P3M's jammy binary repo. Nothing is compiled, so the restore succeeds and it reports No updates needed, skipping PR creation. Workflows 01 and 03 run inside the container and build from source against the current R, where the same lockfile can fail outright. Trust 03, not 02.

Diagnose before you fix. A red build 01 has at least three distinct causes: a renv cache miss, a CRAN pin too old for the runner's R, and a Bioconductor release tied to an older R. They look alike from the outside and need different fixes, so read the failing step rather than reasoning from what was wrong with a different lesson. Note that logs for failed runs expire; if the log has gone, re-run the workflow to get a fresh one.

Check the PR that 02 opens before merging it. With force-renv-init: true, vise::ci_update() calls renv::init(), and renv::init() unconditionally writes a .Rprofile at the repository root containing:

source("renv/activate.R")

The PR is assembled by create-pull-request, which commits every changed file in the working tree with no curation, so that file rides along with the lockfile. Neither sandpaper nor vise puts it there and no Carpentries lesson tracks one; it is renv's side effect leaking through a rarely-used option.

Delete it from the branch before merging. CI will not complain, because the build container sets RENV_CONFIG_EXTERNAL_LIBRARIES and keeps the toolchain visible even with the profile active, but your machine has no such setting: every R session started in that directory would activate lesson-requirements, collapse .libPaths() to the lesson library, and hide sandpaper. That is the failure described at the top of this page.

git fetch origin update/packages
git checkout -B update/packages origin/update/packages
git rm .Rprofile
git commit -m "Drop the .Rprofile that renv::init wrote during the lockfile rebuild"
git push origin update/packages

Then merge. Merging closes the PR, which triggers 03 automatically.

CI builds fine but nothing is pinned. renv.lock is not committed. Check git ls-files renv lists it, and check .gitignore does not contain a bare renv/ line.

Bioconductor-heavy lessons take forever on first build. Expected. manage_deps() is compiling into a fresh profile library. Subsequent builds link from the renv cache and are fast.

Python in an R Markdown lesson

R Markdown episodes can run Python chunks through reticulate. Since reticulate 1.41 you no longer manage a conda or virtualenv by hand: py_require() declares what the episode needs and reticulate provisions an ephemeral environment with uv, downloading both the interpreter and the packages.

Put this in the first chunk of the episode:

```{r setup, include=FALSE}
Sys.setenv(RETICULATE_PYTHON = "managed")   # ignore any conda/system python on PATH
library(reticulate)
py_require(
  c("pandas", "matplotlib"),
  python_version = "3.12",
  exclude_newer  = "2026-08-01"             # freeze resolution to this date
)
```

Then write Python chunks normally, and cross the language boundary with py$name from R and r.name from Python:

```{python}
import pandas as pd
df = pd.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]})
df.describe()
```

```{r}
summary(py$df)
```

Three things worth knowing:

  • RETICULATE_PYTHON = "managed" is not optional in practice. reticulate walks an order of discovery and uses the ephemeral environment only if it finds nothing earlier. On a machine with miniforge on the PATH, conda wins and your py_require() declarations are silently ignored.
  • renv.lock records R packages only and will never record Python packages. exclude_newer is the lightweight substitute: it caps package resolution at a date, so a rebuild next year still resolves the versions that were current when you wrote the episode. Pin exact versions ("pandas==2.2.3") if you need more than that.
  • This needs no change to CI. reticulate fetches uv into its own cache inside the carpentries/workbench-docker container, and uv fetches the interpreter. There is nothing to add to the workflow files.

reticulate is an R package, so manage_deps() picks it up into renv.lock automatically once an episode calls library(reticulate).

When to use conda instead

py_require() resolves from PyPI. If a lesson needs a heavyweight bioconda stack, or tools that are not on PyPI at all, fall back to a conda environment:

  1. Commit an environment.yml at the lesson root.

  2. In the setup chunk, point at it explicitly rather than using "managed":

    Sys.setenv(RETICULATE_PYTHON = "/opt/conda/envs/lesson/bin/python")
    library(reticulate)
  3. Add a conda setup step to .github/workflows/docker_build_deploy.yaml that creates the environment at that path before the build step runs.

This is more work and more to maintain, so use it only when PyPI genuinely cannot supply the tools. Also consider whether the episode should be showing terminal output rather than executing a bioinformatics pipeline at build time.

How CI uses all this

Worth knowing when a build behaves unexpectedly:

  • carpentries/actions/renv-checks sets renv-needed from the presence of renv/profiles/lesson-requirements/renv.lock.
  • The GitHub Actions cache key is a hash of that lockfile, scoped by OS and R version, since packages are compiled for both.
  • The build container runs with RENV_PROFILE=lesson-requirements, RENV_PATHS_ROOT=/home/rstudio/lesson/renv and RENV_CONFIG_EXTERNAL_LIBRARIES=/usr/local/lib/R/site-library, so it reuses the toolchain baked into the image and only installs the lesson's own packages.
  • The scheduled workflow 02 Maintain: Check for Updated Packages opens a PR titled "Update N packages" that bumps renv.lock. Merge these. A comment on the PR shows how the rendered output changed, so you can see whether an update broke anything before it reaches main.
  • Workflow files themselves are updated by sandpaper::update_github_workflows() or the 04 Maintain: Update Workflow Files action.
  • The build container is carpentries/workbench-docker, built FROM rocker/rstudio:latest, so CI always runs the current R release. Workflow 02 runs outside the container on a plain runner with install-r: false, which picks up the runner image's R, currently the same version. A lockfile that only works on an older R will therefore fail in both places.

01 fails with "renv cache miss" right after you commit a lockfile

Fail on renv cache miss
A cached renv environment is required to build this lesson but none is available in the cache.

Not a broken lesson. The Actions cache key is the lockfile hash, so a changed lockfile has no cache to match, and the build refuses to install 200 packages inline. Rebuild the cache:

  1. 02 Maintain: Check for Updated Packages
  2. merge the "Update N packages" PR it opens
  3. 03 Maintain: Apply Package Cache
  4. 01 Maintain: Build and Deploy Site now passes

Run this whenever you commit a lockfile by hand. If 02 itself fails, read its log before rerunning: it is where an R/Bioconductor mismatch surfaces.

Further reading