Site R extras on EESSI

Turn a CRAN DESCRIPTION, CRAN JSON object, or package list into a Bundle easyconfig with exts_defaultclass = 'RPackage'. Base-R packages (methods, utils, …) are dropped. R (>= x) becomes a dependency on the EasyBuild R module. Packages already listed in R-bundle-CRAN exts_list are satisfied as provides.

Inputs

  1. a DESCRIPTION file, cran.json, or a one-spec-per-line package list;

  2. the EESSI robot tree;

  3. a stack policy that locks R and R-bundle-CRAN to the versions that EESSI version ships. See examples/stacks/eessi-r-extras.toml.

Inspect

eb-stack package inspect \
  --source fixtures/foreign_ingest/cran_jsonlite/DESCRIPTION \
  --format cran \
  --toolchain-name foss \
  --toolchain-version 2026.1 \
  --out-dir /tmp/jsonlite-inspect

package.plan.json has origin = cran and easyblock = Bundle.

Plan

eb-stack package plan \
  --source fixtures/foreign_ingest/cran_jsonlite/cran.json \
  --format cran \
  --toolchain-name foss \
  --toolchain-version 2026.1 \
  --easyconfigs /cvmfs/software.eessi.io/versions/2025.06/software/linux/x86_64/amd/zen4/software/EasyBuild/5.3.0/easybuild/easyconfigs \
  --stack-policy examples/stacks/eessi-r-extras.toml \
  --source-checksum <sha256-of-the-tarball> \
  --out-dir /tmp/jsonlite

DESCRIPTION files rarely carry a SHA-256. Pass --source-checksum for the tarball, or use a JSON document that already includes one. Plan refuses to emit a buildable bundle without it.

Install with the eessi-extend target

Same as the Python extras workflow: plan on the host, then

eb-stack campaign run \
  --bundle /tmp/jsonlite \
  --config examples/targets/eessi-extend.toml \
  --target eessi-extend-user \
  --state /tmp/jsonlite.campaign.json

Project one-offs can keep using R_LIBS_USER instead of a site bundle.

Dependencies that state no version

A CRAN package routinely imports another by bare name, with no version at all. Nothing is wrong with that, but an exts_list entry needs one concrete version, so planning stops with

matrixStats is not in the robot, and ">=0" names no version to install as an
extension. Give the ecosystem index with --package-index, or pin the dependency

Give it the index CRAN publishes:

curl -sSLO https://cran.r-project.org/src/contrib/PACKAGES

eb-stack package plan \
  --source brms/DESCRIPTION \
  --format cran \
  --toolchain-name foss --toolchain-version 2025b \
  --easyconfigs "$EBROOTEASYBUILD/easybuild/easyconfigs" \
  --stack-policy examples/stacks/eessi-r-extras.toml \
  --package-index PACKAGES \
  --source-checksum <sha256 of the package tarball> \
  --out-dir /tmp/brms

The index is a control file: stanzas of Field: value separated by blank lines. Versions for unpinned leftovers come from it, and so do their checksums, since CRAN publishes an MD5sum per package and EasyBuild accepts a tagged md5: value. It is a file, not a network call, so a plan stays reproducible and works offline against a copy taken at a known time.

Without an index the error above is the right outcome: installing whatever version happens to be current, unrecorded, is worse than stopping.

When this emits a Bundle

A CRAN package whose imports the robot already carries emits one RPackage recipe. One whose imports it does not carry emits a Bundle with exts_defaultclass = 'RPackage' and the leftovers in exts_list, the same shape upstream uses for R-bundle-CRAN:

easyblock = 'Bundle'
exts_defaultclass = 'RPackage'
exts_list = [
    ('processx', '3.8.4', {...}),
]

The choice is made from the plan rather than by a flag: leftovers exist or they do not. Both shapes are pinned by tests, plan_cran_emits_a_single_r_package and plan_cran_with_leftovers_emits_an_r_bundle.

An already-provided import collapses to its provider instead of becoming an extension, through the same mechanism the Python side uses: an R-bundle-CRAN in the robot tree satisfies a requirement for one of its exts_list entries.