Skip to content

Build recipes that declare the R strategy - #29

Open
jorgeMFS wants to merge 5 commits into
add-r-build-strategyfrom
add-r-builder
Open

jorgeMFS wants to merge 5 commits into
add-r-build-strategyfrom
add-r-builder

Conversation

@jorgeMFS

@jorgeMFS jorgeMFS commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #28, which adds strategy: r to the schema. Base it on add-r-build-strategy, not master — a recipe using this strategy will not validate without #28. The diff shown here is just the builder.

What it does

Runs rwasm in the prebuilt webR container, which already carries Emscripten, a wasm-targeting LLVM flang for the Fortran a large part of CRAN still contains, a host R, and rwasm itself. Reassembling that here would mean owning a toolchain nobody wants to maintain. The image tag comes from the recipe's webrVersion, exactly as the emscripten strategy takes its version from emscriptenVersion.

Why the per-operation copying differs

An R operation has no compiled binary. What gets published is:

  • the package library as an Emscripten filesystem image (library.data.gz + library.js.metadata), shared by every operation in the recipe, and
  • the script named in bin, which ships with the recipe rather than being produced by the build.

So the bundle carries an r block instead of a wasm one:

"runtime": {
  "modes": ["wasm"],
  "r": { "library_digest": "sha256:…", "metadata_digest": "sha256:…", "script_digest": "sha256:…" }
}

A script named in bin but missing from the recipe directory is reported against the operation that asked for it, rather than surfacing as a bare copyfile error naming only a path.

Two rwasm behaviours worked around

Both otherwise produce a build that reports success and ships nothing usable — and both were observed in #27, not guessed at:

  • add_pkg() resolves its entire built-in webr-remotes list on every call, whatever was actually requested, and that resolution currently fails in this container (res_one_row_df: nrow(out) must equal 1). remotes = NULL skips it.
  • add_pkg() reduces a failed package build to a warning() and continues, exiting 0 having produced no binary. The requested packages are checked against what actually landed.

The package list reaches the container as an environment variable rather than interpolated into the R source, so nothing from a recipe is parsed as code.

Verification, and its limits

The container path needs Docker and is only exercised in CI — I could not run it locally, so treat the actual docker run as unverified until it runs here. What is verified, against a stubbed build:

  • an R recipe produces the expected registry tree and a bundle carrying the three digests, with no wasm block
  • a missing script fails with a message naming the recipe and operation
  • an emscripten recipe still produces exactly its .js and .wasm, with an unchanged wasm block — the C path is untouched

Note the repo already requires Python 3.12+: builder.py uses PEP 701 nested quotes in f-strings, which is why the CI pins 3.12. Worth knowing if anyone runs the hub on an older interpreter.

Known cost

The library image is copied per operation, so a recipe with several operations duplicates it in each plugin directory. That matches how the C strategies already give each operation its own binary, and the images are small (~340 KB for two packages), but a shared layer would be the better end state once more than one R recipe exists.


Merge order

Stacked on #28base it on add-r-build-strategy, not master, and merge #28 first.

sbom-implementation is the priority branch and rewrites parts of the builders. This PR conflicts with it on hub/builders/builder.py and hub/validate/validate.py. Rebase after it lands.

Runs rwasm in the prebuilt webR container, which already carries
Emscripten, a wasm-targeting LLVM flang for the Fortran a large part of
CRAN still contains, a host R and rwasm itself. Reassembling that would
mean owning a toolchain nobody here wants to maintain. The image tag
comes from the recipe's webrVersion, as the emscripten strategy takes
its version from emscriptenVersion.

An R operation has no compiled binary, so the per-operation copying
differs from the C strategies. What gets published is the package
library as an Emscripten filesystem image, which every operation in the
recipe shares, plus the script named in `bin`, which ships with the
recipe rather than being produced by the build. A script named in `bin`
but absent from the recipe directory is reported against the operation
that asked for it, rather than surfacing as a bare copy error.

Two rwasm behaviours are worked around in the build script, both of
which otherwise yield a build that reports success and ships nothing
usable. add_pkg() resolves every reference in its built-in webr-remotes
list on each call whatever was requested, and that resolution currently
fails in this container, so remotes is passed as NULL. It also reduces a
failed package build to a warning and continues, exiting 0 having
produced no binary, so the requested packages are checked against what
actually landed. The package list reaches the container as an
environment variable rather than interpolated into the R source.

The container path itself needs Docker and is exercised in CI. What is
verified here is the surrounding logic, against a stubbed build: the
registry tree and bundle for an R recipe carry the library, metadata and
script digests and no wasm block; a missing script fails with a message
naming the operation; and an emscripten recipe still produces exactly
its .js and .wasm with an unchanged wasm block.
Five defects, none of which would have shown up in a passing build.

An operation's `bin` was typed as a bare string. Under the R strategy it
names a script inside the contributed recipe directory and is used to
build a path, so a recipe could reach outside its own directory with
"../.." and have whatever it found copied into the plugin folder, which
publish then pushes to the registry wholesale. It is now constrained to
a filename, matching how `id` is already constrained.

The check that every requested package produced a binary only recovered
a package name from plain, cran:: and @Version references. A GitHub or
url reference reduced to something that could never match its own
artefact, so a build that succeeded was reported as having produced
nothing.

remotes = NULL skips rwasm's webR-patched forks as well as the failing
resolution it was added for. That cost is now recorded where the
decision is made, and a package rwasm keeps a patched fork of is refused
outright rather than quietly built from unpatched CRAN source and left
to misbehave in a browser.

A recipe could declare an R wasm build and a native build together. The
two give `bin` incompatible meanings, so it validated and then failed in
the native copy step looking for a compiled artifact under a script's
name.

The webR version a library was built against is now recorded in the
bundle. These binaries are only loadable by that release, and without it
a routine dependency bump in the frontend would break every published R
bundle with nothing to attribute the failure to.

Verified: eight schema cases including four path-traversal forms, and
all 32 recipes still validate unchanged.
The check catches packages the recipe names. It does not catch one
reached transitively, which dependencies: NA makes possible, since
skipping prefer_remotes() gives up the patched forks throughout the
resolved tree rather than only at its root. Recorded rather than left
for someone to discover.

A missing webr-remotes file also degraded to an empty list, quietly
removing the guard altogether. That is now an error.
A recipe with an empty build.wasm block raised an AttributeError out of
the validator instead of reporting a bad recipe, because the key parses
as None.

The bin pattern used ^ and $, and Python's $ also matches before a
trailing newline, so "summary.R\n" passed a check whose whole purpose is
to require a bare filename. It is anchored with \A and \Z now, and
bounded in length.

The two reference checks in the R builder normalised names differently:
the clash check kept the version suffix while the missing-artifact check
stripped it, so a url:: reference reduced to igraph_2.0.3.tar.gz in one
and igraph in the other, and a patched package could be built from
unpatched CRAN source without tripping the guard meant to refuse it.
Both now share one normaliser.

Verified: six schema cases including the trailing newline, an
over-long name and a leading dash; the normaliser across six reference
forms; and all 32 recipes unchanged.
Cerberus drops type and schema checking for a None value but still runs
check_with, so an empty mapping in a recipe reaches these functions as
None and raises out of the validator instead of being reported as a bad
recipe. The previous commit fixed validate_wasm_strategy; the same fault
was in validate_build_combination and, from before this work,
validate_output_mode, where a bare list entry under outputs triggers it.

All three are guarded, and the null forms are covered: a null build, a
null build.wasm, a null entry in inputs, outputs or parameters, and a
null operation all now report a validation failure. The 32 existing
recipes are unaffected.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant