From 14e492a41fa65443699d5ed360b94355eedbda92 Mon Sep 17 00:00:00 2001 From: BG Date: Thu, 5 Nov 2026 14:41:46 +0100 Subject: [PATCH 1/2] [internal][recipe] Build R packages with bundled libuv and verify them fs >= 2.0.0 requires libuv headers that the stacks do not ship, so shiny and its dependencies silently failed to install (R exits 0 on dependency failures) and were missing from the r 4.6.2 artifacts. - Set USE_BUNDLED_LIBUV=1 for all R package installs. - Fail the build if fs, Rserve, forecast, shiny or plumber are not installed. --- internal/recipe/r.go | 34 ++++++++++- internal/recipe/recipe_compiled_test.go | 79 +++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 3 deletions(-) diff --git a/internal/recipe/r.go b/internal/recipe/r.go index 93909867..8d7e71b2 100644 --- a/internal/recipe/r.go +++ b/internal/recipe/r.go @@ -98,13 +98,17 @@ func (r *RRecipe) Build(ctx context.Context, s *stack.Stack, src *source.Input, // the remotes package separately. Use remotes directly instead. // We also explicitly install packages that devtools used to bundle as freebies // (stringr, cli, etc.) to avoid breaking R apps that depend on them implicitly. + // + // fs >= 2.0.0 (pulled in by shiny via sass/bslib) needs libuv headers, which the + // stacks do not ship. USE_BUNDLED_LIBUV makes it build its vendored libuv instead. + rEnv := map[string]string{"USE_BUNDLED_LIBUV": "1"} remotesCmd := `/usr/local/lib/R/bin/R --vanilla -e 'install.packages("remotes", repos="https://cran.r-project.org")'` - if err := run.Run("sh", "-c", remotesCmd); err != nil { + if err := run.RunWithEnv(rEnv, "sh", "-c", remotesCmd); err != nil { return fmt.Errorf("r: installing remotes: %w", err) } depsCmd := `/usr/local/lib/R/bin/R --vanilla -e 'install.packages(c("cli", "curl", "lifecycle", "R6", "rlang", "withr", "processx", "callr", "stringr"), repos="https://cran.r-project.org")'` - if err := run.Run("sh", "-c", depsCmd); err != nil { + if err := run.RunWithEnv(rEnv, "sh", "-c", depsCmd); err != nil { return fmt.Errorf("r: installing explicit r dependencies: %w", err) } @@ -129,7 +133,7 @@ func (r *RRecipe) Build(ctx context.Context, s *stack.Stack, src *source.Input, `/usr/local/lib/R/bin/R --vanilla -e "require('remotes'); remotes::install_version('%s', '%s', repos='https://cran.r-project.org', type='source', dependencies=TRUE)"`, pkg.name, pkgVersion, ) - if err := run.Run("sh", "-c", rCmd); err != nil { + if err := run.RunWithEnv(rEnv, "sh", "-c", rCmd); err != nil { return fmt.Errorf("r: installing R package %s: %w", pkg.name, err) } @@ -142,6 +146,12 @@ func (r *RRecipe) Build(ctx context.Context, s *stack.Stack, src *source.Input, } } + // R exits 0 when install.packages fails for a dependency, so verify the + // packages (and the dependencies that the buildpack tests rely on) are present. + if err := run.Run("sh", "-c", verifyRPackagesCmd()); err != nil { + return fmt.Errorf("r: verifying installed R packages: %w", err) + } + // Step 7: Remove remotes after use. The explicitly pre-installed packages // (cli, curl, stringr, etc.) are intentionally left in the artifact — they // are R app runtime dependencies that devtools used to bundle as freebies. @@ -178,6 +188,24 @@ func (r *RRecipe) Build(ctx context.Context, s *stack.Stack, src *source.Input, return nil } +// verifyRPackagesCmd returns an R command that exits non-zero if any of the +// sub-dependency packages or the fs package is not installed. +func verifyRPackagesCmd() string { + names := []string{"fs"} + for _, pkg := range rSubDeps { + names = append(names, pkg.name) + } + quoted := make([]string, len(names)) + for i, n := range names { + quoted[i] = fmt.Sprintf("'%s'", n) + } + list := strings.Join(quoted, ", ") + return fmt.Sprintf( + `/usr/local/lib/R/bin/R --vanilla -e "pkgs <- c(%s); missing <- setdiff(pkgs, rownames(installed.packages())); if (length(missing) > 0) stop('missing R packages: ', paste(missing, collapse=', '))"`, + list, + ) +} + // formatRserveVersion converts "1.8.14" → "1.8-14" // (first two dot-separated parts joined by '.', remainder joined by '-'). func formatRserveVersion(v string) string { diff --git a/internal/recipe/recipe_compiled_test.go b/internal/recipe/recipe_compiled_test.go index c0acb3c0..03af6013 100644 --- a/internal/recipe/recipe_compiled_test.go +++ b/internal/recipe/recipe_compiled_test.go @@ -843,6 +843,85 @@ func TestRRecipeRemovesRemotes(t *testing.T) { "should call remove.packages(\"remotes\") after package installs") } +func TestRRecipeUsesBundledLibuvForPackageInstalls(t *testing.T) { + useTempWorkDir(t) + writeRSubDepFiles(t) + + r := &recipe.RRecipe{Fetcher: newFakeFetcher()} + run := runner.NewFakeRunner() + s := newCompiledStack(t) + s.AptPackages["r_build"] = []string{"r-base"} + src := newInput("r", "4.3.1", "https://cran.r-project.org/src/base/R-4.3.1.tar.gz") + + require.NoError(t, r.Build(context.Background(), s, src, run, &output.OutData{})) + + installs := 0 + for _, c := range run.Calls { + joined := strings.Join(c.Args, " ") + if c.Name == "sh" && (strings.Contains(joined, "install.packages(") || strings.Contains(joined, "install_version")) { + installs++ + assert.Equal(t, "1", c.Env["USE_BUNDLED_LIBUV"], "package install must set USE_BUNDLED_LIBUV: %s", joined) + } + } + assert.Equal(t, 6, installs, "expected remotes, explicit deps, and 4 sub-dependency installs") +} + +func TestRRecipeVerifiesPackagesBeforeRemovingRemotes(t *testing.T) { + useTempWorkDir(t) + writeRSubDepFiles(t) + + r := &recipe.RRecipe{Fetcher: newFakeFetcher()} + run := runner.NewFakeRunner() + s := newCompiledStack(t) + s.AptPackages["r_build"] = []string{"r-base"} + src := newInput("r", "4.3.1", "https://cran.r-project.org/src/base/R-4.3.1.tar.gz") + + require.NoError(t, r.Build(context.Background(), s, src, run, &output.OutData{})) + + verifyIdx, removeIdx := -1, -1 + for i, c := range run.Calls { + joined := strings.Join(c.Args, " ") + if c.Name == "sh" && strings.Contains(joined, "installed.packages()") { + verifyIdx = i + for _, pkg := range []string{"'fs'", "'shiny'", "'forecast'", "'plumber'", "'Rserve'"} { + assert.Contains(t, joined, pkg) + } + } + if c.Name == "sh" && strings.Contains(joined, `remove.packages("remotes")`) { + removeIdx = i + } + } + require.True(t, verifyIdx >= 0, "package verification call not found") + assert.Less(t, verifyIdx, removeIdx) +} + +func TestRRecipeFailsWhenPackageVerificationFails(t *testing.T) { + useTempWorkDir(t) + writeRSubDepFiles(t) + + s := newCompiledStack(t) + s.AptPackages["r_build"] = []string{"r-base"} + src := newInput("r", "4.3.1", "https://cran.r-project.org/src/base/R-4.3.1.tar.gz") + + // First pass records the verification command so the second can fail on it. + probe := runner.NewFakeRunner() + r := &recipe.RRecipe{Fetcher: newFakeFetcher()} + require.NoError(t, r.Build(context.Background(), s, src, probe, &output.OutData{})) + var verifyKey string + for _, c := range probe.Calls { + if c.Name == "sh" && strings.Contains(strings.Join(c.Args, " "), "installed.packages()") { + verifyKey = "sh " + strings.Join(c.Args, " ") + } + } + require.NotEmpty(t, verifyKey) + + run := runner.NewFakeRunner() + run.ErrorMap = map[string]error{verifyKey: fmt.Errorf("missing R packages: fs")} + err := (&recipe.RRecipe{Fetcher: newFakeFetcher()}).Build(context.Background(), s, src, run, &output.OutData{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "verifying installed R packages") +} + func TestRserveVersionFormatting(t *testing.T) { useTempWorkDir(t) writeRSubDepFiles(t) From 758120aa5c7d5f641f8984961019c0b511212977 Mon Sep 17 00:00:00 2001 From: BG Date: Thu, 5 Nov 2026 14:52:10 +0100 Subject: [PATCH 2/2] [internal][recipe] Verify explicit R deps and check packages load --- internal/recipe/export_test.go | 4 +++ internal/recipe/r.go | 36 +++++++++++++++++-------- internal/recipe/recipe_compiled_test.go | 19 +++---------- 3 files changed, 33 insertions(+), 26 deletions(-) create mode 100644 internal/recipe/export_test.go diff --git a/internal/recipe/export_test.go b/internal/recipe/export_test.go new file mode 100644 index 00000000..bff90b43 --- /dev/null +++ b/internal/recipe/export_test.go @@ -0,0 +1,4 @@ +package recipe + +// VerifyRPackagesCmd exposes verifyRPackagesCmd to the external test package. +func VerifyRPackagesCmd() string { return verifyRPackagesCmd() } diff --git a/internal/recipe/r.go b/internal/recipe/r.go index 8d7e71b2..ceac9073 100644 --- a/internal/recipe/r.go +++ b/internal/recipe/r.go @@ -36,6 +36,10 @@ type rPackage struct { srcDir string // e.g. "source-forecast-latest" } +// rExplicitDeps are packages that devtools used to bundle as freebies. They are +// installed up front and stay in the artifact as R app runtime dependencies. +var rExplicitDeps = []string{"cli", "curl", "lifecycle", "R6", "rlang", "withr", "processx", "callr", "stringr"} + // rSubDeps lists the R packages to install, in the same order as the Ruby builder: // Rserve, forecast, shiny, plumber. var rSubDeps = []rPackage{ @@ -107,7 +111,10 @@ func (r *RRecipe) Build(ctx context.Context, s *stack.Stack, src *source.Input, return fmt.Errorf("r: installing remotes: %w", err) } - depsCmd := `/usr/local/lib/R/bin/R --vanilla -e 'install.packages(c("cli", "curl", "lifecycle", "R6", "rlang", "withr", "processx", "callr", "stringr"), repos="https://cran.r-project.org")'` + depsCmd := fmt.Sprintf( + `/usr/local/lib/R/bin/R --vanilla -e 'install.packages(c(%s), repos="https://cran.r-project.org")'`, + quoteRNames(`"`, rExplicitDeps), + ) if err := run.RunWithEnv(rEnv, "sh", "-c", depsCmd); err != nil { return fmt.Errorf("r: installing explicit r dependencies: %w", err) } @@ -188,21 +195,28 @@ func (r *RRecipe) Build(ctx context.Context, s *stack.Stack, src *source.Input, return nil } -// verifyRPackagesCmd returns an R command that exits non-zero if any of the -// sub-dependency packages or the fs package is not installed. +// quoteRNames wraps each name in the given quote character and joins them +// into the body of an R c(...) call. +func quoteRNames(quote string, names []string) string { + quoted := make([]string, len(names)) + for i, n := range names { + quoted[i] = quote + n + quote + } + return strings.Join(quoted, ", ") +} + +// verifyRPackagesCmd returns an R command that exits non-zero if fs, the +// explicitly installed dependencies or the sub-dependency packages cannot be +// loaded. Loading (not just being listed as installed) also catches a broken +// native build such as fs against the bundled libuv. func verifyRPackagesCmd() string { - names := []string{"fs"} + names := append([]string{"fs"}, rExplicitDeps...) for _, pkg := range rSubDeps { names = append(names, pkg.name) } - quoted := make([]string, len(names)) - for i, n := range names { - quoted[i] = fmt.Sprintf("'%s'", n) - } - list := strings.Join(quoted, ", ") return fmt.Sprintf( - `/usr/local/lib/R/bin/R --vanilla -e "pkgs <- c(%s); missing <- setdiff(pkgs, rownames(installed.packages())); if (length(missing) > 0) stop('missing R packages: ', paste(missing, collapse=', '))"`, - list, + `/usr/local/lib/R/bin/R --vanilla -e "pkgs <- c(%s); missing <- pkgs[!vapply(pkgs, requireNamespace, logical(1), quietly=TRUE)]; if (length(missing) > 0) stop('missing R packages: ', paste(missing, collapse=', '))"`, + quoteRNames("'", names), ) } diff --git a/internal/recipe/recipe_compiled_test.go b/internal/recipe/recipe_compiled_test.go index 03af6013..2fbc7116 100644 --- a/internal/recipe/recipe_compiled_test.go +++ b/internal/recipe/recipe_compiled_test.go @@ -863,7 +863,7 @@ func TestRRecipeUsesBundledLibuvForPackageInstalls(t *testing.T) { assert.Equal(t, "1", c.Env["USE_BUNDLED_LIBUV"], "package install must set USE_BUNDLED_LIBUV: %s", joined) } } - assert.Equal(t, 6, installs, "expected remotes, explicit deps, and 4 sub-dependency installs") + assert.Greater(t, installs, 0, "expected package install calls") } func TestRRecipeVerifiesPackagesBeforeRemovingRemotes(t *testing.T) { @@ -881,9 +881,9 @@ func TestRRecipeVerifiesPackagesBeforeRemovingRemotes(t *testing.T) { verifyIdx, removeIdx := -1, -1 for i, c := range run.Calls { joined := strings.Join(c.Args, " ") - if c.Name == "sh" && strings.Contains(joined, "installed.packages()") { + if c.Name == "sh" && strings.Contains(joined, "requireNamespace") { verifyIdx = i - for _, pkg := range []string{"'fs'", "'shiny'", "'forecast'", "'plumber'", "'Rserve'"} { + for _, pkg := range []string{"'fs'", "'shiny'", "'forecast'", "'plumber'", "'Rserve'", "'cli'", "'curl'", "'processx'", "'stringr'"} { assert.Contains(t, joined, pkg) } } @@ -903,19 +903,8 @@ func TestRRecipeFailsWhenPackageVerificationFails(t *testing.T) { s.AptPackages["r_build"] = []string{"r-base"} src := newInput("r", "4.3.1", "https://cran.r-project.org/src/base/R-4.3.1.tar.gz") - // First pass records the verification command so the second can fail on it. - probe := runner.NewFakeRunner() - r := &recipe.RRecipe{Fetcher: newFakeFetcher()} - require.NoError(t, r.Build(context.Background(), s, src, probe, &output.OutData{})) - var verifyKey string - for _, c := range probe.Calls { - if c.Name == "sh" && strings.Contains(strings.Join(c.Args, " "), "installed.packages()") { - verifyKey = "sh " + strings.Join(c.Args, " ") - } - } - require.NotEmpty(t, verifyKey) - run := runner.NewFakeRunner() + verifyKey := "sh -c " + recipe.VerifyRPackagesCmd() run.ErrorMap = map[string]error{verifyKey: fmt.Errorf("missing R packages: fs")} err := (&recipe.RRecipe{Fetcher: newFakeFetcher()}).Build(context.Background(), s, src, run, &output.OutData{}) require.Error(t, err)