build-cran-binaries/docs/superpowers/specs/2026-05-25-multi-r-version-images-design.md
pat-s 2432f3ff94
docs: add spec for multi-R-version image refactor
Image tag drops the R version (e.g., build-env-alpine:3.23 instead of
:3.23-4.5); workflows pick an R version by calling
/opt/R/${R_VERSION}/bin/R explicitly.
Includes three folded-in bug fixes (alpine audit images, package-index
images, ubuntu-2404 R patch).
2026-05-25 20:08:31 +02:00

13 KiB

Refactor build workflows to multi-R-version images

Goal

The container images at reg.devxy.io/rpkgs/build-env-* are moving from a one-R-version-per-tag model to a multi-R-version-per-tag model. The image tag now encodes only the OS version (e.g. build-env-alpine:3.23), and each image ships several R installs under /opt/R/<patch>/. Workflows and recipes must select an R version explicitly by calling /opt/R/${R_VERSION}/bin/R instead of relying on R/Rscript from PATH.

Scope

In scope:

  • Every .crow/*.yaml workflow that references a build-env-* image (64 files).
  • Justfile recipes that run docker run against a build-env-* image (3 recipes).
  • The commented-out build-all-versions-install-deps.yaml in the repo root (kept consistent so the example doesn't go stale).
  • Three correctness bug fixes that the user asked to roll into the same change:
    • The six weekly-audit-missing-alpine-{321,322,323}-{amd64,arm64}.yaml files all incorrectly use alpine:3.23-4.5; each should use its own alpine image.
    • The fourteen update-package-index-*.yaml files all use build-env-ubuntu:noble-4.4 regardless of the platform they index; each should use its own platform's image.
    • process-updates-ubuntu-2404-{amd64,arm64}.yaml use noble-4.4 while the audit and rebuild counterparts use noble-4.4.3; align to 4.4.3.

Out of scope:

  • local/build-all.R and other R scripts run inside a container with Rscript. Once R is launched, child processes inherit R.home(); the scripts themselves need no change.
  • docker/, benchmark/.
  • Historical docs in docs/superpowers/plans/ and docs/superpowers/specs/ that reference old image tags.
  • Any workflow restructuring beyond image and R-path changes plus the three bug fixes above.

Image and R-path scheme

New image tag:

reg.devxy.io/rpkgs/build-env-${OS}:${OS_VERSION}

R_VERSION is no longer encoded in the tag. Each image contains R installs under /opt/R/<patch>/, accessed via:

  • /opt/R/${R_VERSION}/bin/R
  • /opt/R/${R_VERSION}/bin/Rscript

R_VERSION is always a full patch string (e.g. 4.5.3, 4.4.3), never a minor (4.5).

Platform → image + R_VERSION mapping

Platform New image R_VERSION
alpine-322 reg.devxy.io/rpkgs/build-env-alpine:3.22 4.5.3
alpine-323 reg.devxy.io/rpkgs/build-env-alpine:3.23 4.5.3
ubuntu-2204 reg.devxy.io/rpkgs/build-env-ubuntu:jammy 4.4.3
ubuntu-2404 reg.devxy.io/rpkgs/build-env-ubuntu:noble 4.4.3
redhat-8 reg.devxy.io/rpkgs/build-env-redhat:8 4.4.3
redhat-9 reg.devxy.io/rpkgs/build-env-redhat:9 4.4.3
redhat-10 reg.devxy.io/rpkgs/build-env-redhat:10 4.5.3

The alpine-321 platform has no matching new image; its two audit-only workflows fall back to build-env-alpine:3.23 with R_VERSION=4.5.3 (rationale in the "Edge cases" section).

How workflows reference R

Two patterns appear in the repo today:

  1. Hard-coded image, no R_VERSION env var. The R version is implicit in the image tag.
  2. Parameterised image via matrix/--var. R_VERSION is already an environment variable; the image tag interpolates ${R_VERSION}.

After the refactor:

  • Pattern (1) workflows gain a single R_VERSION: entry in their environment: block. All R … and Rscript … invocations in the commands: block become /opt/R/${R_VERSION}/bin/R … / /opt/R/${R_VERSION}/bin/Rscript ….
  • Pattern (2) workflows keep their existing R_VERSION value source (caller-supplied --var); only the image tag and the R invocations change.

No PATH munging, no wrapper script, no shell aliasing. Every R call site is explicit about which R is invoked.

Example: pattern (1) before → after

Before (excerpt from process-updates-alpine-322-amd64.yaml):

- name: 'Processing Updates'
  image: reg.devxy.io/rpkgs/build-env-alpine:3.22-4.5
  environment:
    PLATFORM: alpine-322
    ARCH: amd64
    # ...
  commands:
    - R -q -e 'pak::pak("git::https://codefloe.com/rpkgs/bincraft.git")'
    - R -q -e 'packageVersion("bincraft")'
    - xvfb-run R -q -e "..."

After:

- name: 'Processing Updates'
  image: reg.devxy.io/rpkgs/build-env-alpine:3.22
  environment:
    PLATFORM: alpine-322
    ARCH: amd64
    R_VERSION: 4.5.3
    # ...
  commands:
    - /opt/R/${R_VERSION}/bin/R -q -e 'pak::pak("git::https://codefloe.com/rpkgs/bincraft.git")'
    - /opt/R/${R_VERSION}/bin/R -q -e 'packageVersion("bincraft")'
    - xvfb-run /opt/R/${R_VERSION}/bin/R -q -e "..."

Example: pattern (2) before → after

Before (excerpt from build-all-versions-amd64.yaml):

- name: 'Build binaries'
  image: reg.devxy.io/rpkgs/build-env-${OS}:${OS_VERSION}-${R_VERSION}
  commands:
    - $XVFB $XVFB_ARGS -n $SPLIT_INDEX -- Rscript local/build-all.R $SPLIT_INTO $SPLIT_INDEX $NCPUS 2>&1
    - R -q -e "bincraft::process_unarchived_pkgs(...)"

After:

- name: 'Build binaries'
  image: reg.devxy.io/rpkgs/build-env-${OS}:${OS_VERSION}
  commands:
    - $XVFB $XVFB_ARGS -n $SPLIT_INDEX -- /opt/R/${R_VERSION}/bin/Rscript local/build-all.R $SPLIT_INTO $SPLIT_INDEX $NCPUS 2>&1
    - /opt/R/${R_VERSION}/bin/R -q -e "bincraft::process_unarchived_pkgs(...)"

R_VERSION (e.g. 4.5.3) is already supplied by the crow pipeline create --var invocations documented in the file header.

Files touched

A. .crow/build-all-versions-*.yaml (4 files, pattern 2)

  • .crow/build-all-versions-amd64.yaml
  • .crow/build-all-versions-arm64.yaml
  • .crow/build-all-versions-install-deps-amd64.yaml
  • .crow/build-all-versions-install-deps-arm64.yaml

Change: drop -${R_VERSION} from the image tag; substitute the explicit R path in every R/Rscript invocation. R_VERSION already arrives via --var.

B. .crow/process-updates-*.yaml (14 files, pattern 1)

  • process-updates-alpine-322-{amd64,arm64}.yaml
  • process-updates-alpine-323-{amd64,arm64}.yaml
  • process-updates-ubuntu-2204-{amd64,arm64}.yaml
  • process-updates-ubuntu-2404-{amd64,arm64}.yaml
  • process-updates-redhat-8-{amd64,arm64}.yaml
  • process-updates-redhat-9-{amd64,arm64}.yaml
  • process-updates-redhat-10-{amd64,arm64}.yaml

Change: image swap per mapping table; add R_VERSION: env var; substitute R path in every R/Rscript/xvfb-run R invocation. The two ubuntu-2404 files also bump from 4.4 to 4.4.3 (bug fix; see "Edge cases").

C. .crow/weekly-rebuild-missing-*.yaml (14 files, pattern 1)

One per platform/arch listed in the mapping table. Same treatment as B.

D. .crow/weekly-audit-missing-*.yaml (16 files, pattern 1)

Same treatment as B, plus repointing each alpine audit file to its own alpine image:

File New image R_VERSION
weekly-audit-missing-alpine-321-amd64.yaml build-env-alpine:3.23 (no 3.21 image) 4.5.3
weekly-audit-missing-alpine-321-arm64.yaml build-env-alpine:3.23 (no 3.21 image) 4.5.3
weekly-audit-missing-alpine-322-amd64.yaml build-env-alpine:3.22 4.5.3
weekly-audit-missing-alpine-322-arm64.yaml build-env-alpine:3.22 4.5.3
weekly-audit-missing-alpine-323-amd64.yaml build-env-alpine:3.23 4.5.3
weekly-audit-missing-alpine-323-arm64.yaml build-env-alpine:3.23 4.5.3

The non-alpine audit files follow the mapping table directly.

E. .crow/update-package-index-*.yaml (14 files, pattern 1)

Each currently uses build-env-ubuntu:noble-4.4 regardless of which platform's package index it uploads. Repoint each to its own platform's image and R_VERSION per the mapping table.

Files:

  • update-package-index-alpine-322-{amd64,arm64}.yaml
  • update-package-index-alpine-323-{amd64,arm64}.yaml
  • update-package-index-ubuntu-2204-{amd64,arm64}.yaml
  • update-package-index-ubuntu-2404-{amd64,arm64}.yaml
  • update-package-index-redhat-8-{amd64,arm64}.yaml
  • update-package-index-redhat-9-{amd64,arm64}.yaml
  • update-package-index-redhat-10-{amd64,arm64}.yaml

The second step in each (Purge CDN cache) runs on alpine:3.23 and does not invoke R; it is unchanged.

F. .crow/archive-missed-packages.yaml (1 file)

Currently uses build-env-alpine:3.23-4.5. The image OS doesn't matter for this workflow (it only writes to S3 + Postgres). New: build-env-alpine:3.23 + R_VERSION: 4.5.3. Same R-path substitution as elsewhere.

G. .crow/build-r-minor-sensitive-packages.yaml (1 file)

Special case: uses docker.io/devxygmbh/rpkgs-build-env-${os}:${os_version}-${r_version} (lowercase matrix vars; different registry).

Decision (user-confirmed): keep the docker.io/devxygmbh/ registry. Drop the -${r_version} suffix from the image tag, leaving docker.io/devxygmbh/rpkgs-build-env-${os}:${os_version}. Substitute every R/Rscript for /opt/R/${r_version}/bin/R / /opt/R/${r_version}/bin/Rscript.

The workflow's matrix continues to use r_version: 4.5 / 4.4. To remain consistent with the rest of the refactor's "always full patch" rule, the matrix values should be updated to 4.5.3 and 4.4.3 respectively (matching alpine-321's R 4.5.3 and the historical 4.4.3 patch).

H. Justfile (3 recipes)

  • build-all OS OS_VERSION ARCH R_VERSION PACKAGE NCPUS
  • build-single OS OS_VERSION ARCH R_VERSION PACKAGE TAG NCPUS
  • process-updates OS OS_VERSION ARCH R_VERSION interval

Change in each: drop -{{R_VERSION}} from the image tag, and replace every R /R -q -e inside the bash -c '…' string with /opt/R/{{R_VERSION}}/bin/R //opt/R/{{R_VERSION}}/bin/R -q -e. The example comments above each recipe (# just build-all alpine 3.21 arm64 4.5.0 …) should be updated to use a current platform/R combination (e.g. alpine 3.22 amd64 4.5.3).

I. build-all-versions-install-deps.yaml (commented-out, repo root)

Apply the same edits as the active .crow/build-all-versions-install-deps-*.yaml files so the commented-out example remains a faithful template.

Edge cases and bug fixes folded in

  1. Alpine audit images. All six weekly-audit-missing-alpine-{321,322,323}-{amd64,arm64}.yaml files currently point at alpine:3.23-4.5. After the refactor, each one points at the image that matches its own alpine version. alpine-321 has no matching image in the new scheme, so its two files use build-env-alpine:3.23 (the audit workflow reads PLATFORM from env and queries S3/CRAN; the container's own OS does not affect correctness).
  2. Package-index workflows. All fourteen update-package-index-*.yaml files are repointed to their own platform's image, matching the rest of the per-platform workflows.
  3. Ubuntu-2404 R version. process-updates-ubuntu-2404-{amd64,arm64}.yaml move from noble-4.4 to build-env-ubuntu:noble + R_VERSION: 4.4.3, matching the audit and rebuild counterparts.

Validation

There is no automated test suite for workflow files in this repo. Validation is:

  1. Static checks per file: after edit, grep each touched workflow for leftover bare R , Rscript , R -q, R -e, R CMD invocations. Any hit that is not part of a longer path (/opt/R/…/bin/R) is a regression.
  2. Image tag check: grep for build-env- lines and confirm no tag still contains -${R_VERSION}, -4.4, -4.4.3, -4.5, or -4.5.3.
  3. Smoke runs: trigger one workflow per shape on a feature branch and confirm green:
    • process-updates-alpine-322-amd64.yaml
    • weekly-rebuild-missing-redhat-9-amd64.yaml
    • weekly-audit-missing-ubuntu-2204-amd64.yaml
    • update-package-index-redhat-10-amd64.yaml
    • archive-missed-packages.yaml
    • build-all-versions-amd64.yaml (with its install-deps predecessor)
    • build-r-minor-sensitive-packages.yaml

The Justfile recipes are exercised by running each once locally against a current platform.

Risks

  • Wrong R_VERSION in a file: typo in the platform→version mapping causes /opt/R/<missing>/bin/R: not found. Mitigated by the static grep in validation and by smoke-running one workflow per shape.
  • build-r-minor-sensitive-packages.yaml assumes new images exist at docker.io/devxygmbh/: if the multi-R image is only published to reg.devxy.io/rpkgs/, this workflow will fail on the first pull. If that turns out to be the case, switch to option (a) — repoint to reg.devxy.io/rpkgs/ — as a follow-up.
  • R subprocesses inside scripts: pak, future, and similar libraries spawn child R processes via R.home(), which is set to the parent's install. No additional action needed.