--- title: "Surviving R CMD check and CI" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Surviving R CMD check and CI} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") library(getaca) .old_options <- options(getaca.cache = file.path(tempdir(), "getaca-checks")) .old_envvars <- Sys.getenv(c("GETACA_OFFLINE", "NOT_CRAN"), unset = NA) Sys.setenv(GETACA_OFFLINE = "true", NOT_CRAN = "true") reg <- registry("yourpkg", list( resource("backbone", "2026-06", urls = "https://host.invalid/backbone-2026-06.zip", sha256 = strrep("9f", 32), license = "CC-BY-4.0") )) ``` CRAN policy on packages that use Internet resources is one sentence, and it is the sentence this article exists to satisfy: > Packages which use Internet resources should fail gracefully with an > informative message if the resource is not available or has changed (and not > give a check warning nor error). A four-gigabyte dependency makes that harder than it sounds. The file cannot ship in the package, the check machine has no network and no cache, and "gracefully" has to hold in tests, in examples and in vignettes, which fail in three different ways. The three failure modes are worth naming, because the fix differs. A test that calls a missing resource throws, and the check reports a failing test. An example that throws stops the check with an error in `R CMD check` output, which is the form CRAN rejects most readily. A vignette that throws fails the build step, and the package does not install at all. None of the three is recoverable by a message, so each needs a guard placed before the call rather than a handler wrapped around it. The rest of this article is those guards, the workflow files that make CI exercise the real path, and the recipe for reproducing a check farm locally before submitting. ## The check clamp Under `R CMD check`, resolution collapses to `offline` whatever the policy is set to. Nothing has to be configured for this: `getaca` reads the environment variables the check process sets. ```{r} getaca_policy() ``` This vignette reports `offline` because it sets `GETACA_OFFLINE` in its setup chunk, which forces the same clamp outside a check run. The check itself is detected from `_R_CHECK_PACKAGE_NAME_` and the other variables the check process exports, so a declaring package needs no configuration to be safe there. The clamp is released by `NOT_CRAN=true`, which `devtools::test()` and `devtools::check(env_vars = c(NOT_CRAN = "true"))` set for you. That is the switch that lets your own CI run the same tests against real resources while a CRAN machine never leaves the box. Setting `GETACA_OFFLINE` to `1`, `true` or `yes` is how you reproduce a check machine's behaviour on your own laptop before submitting. ## The three helpers They answer three different questions, and they are not three front doors onto the same download. | Helper | Question | Returns | Touches the network | |---|---|---|---| | `getaca_available()` | Is this usable right now? | `TRUE` / `FALSE` | never | | `getaca_optional()` | Give me the path if you have it | path or `NULL` | under a network policy | | `getaca_skip_if_unavailable()` | Should this test run? | invisible `NULL`, or skips | never | ### In tests ```{r, eval = FALSE} test_that("the backbone parses", { getaca_skip_if_unavailable("backbone", package = "yourpkg") path <- getaca("backbone", package = "yourpkg") expect_s3_class(read_backbone(path), "backbone") }) ``` The skip message names the missing resource, the package that declared it, and the call that would fetch it: ``` external resource 'backbone' (declared by yourpkg) is not cached; prefetch with getaca_prefetch("backbone", package = "yourpkg") ``` That matters more than it looks. A skipped test on someone else's CI is a question, and a skip reason that answers it is the difference between a bug report and a `getaca_prefetch()` call. `getaca_skip_if_unavailable()` needs `testthat`, which it asks for at call time, so `getaca` does not depend on a test framework in order to help tests. ### In examples ```{r, eval = FALSE} #' @examples #' path <- getaca_optional("backbone", package = "yourpkg") #' if (!is.null(path)) { #' summarise_backbone(path) #' } ``` `getaca_optional()` catches every `getaca_error` and returns `NULL` with a message, so an example runs to completion on a machine that has nothing. This vignette is built with the network switched off, which is the same path a check machine takes: ```{r} path <- getaca_optional("backbone", registry = reg) is.null(path) ``` For a resource large enough that fetching it is unreasonable even where the network exists, `\donttest{}` is the honest wrapper. `getaca_optional()` handles absence; `\donttest{}` handles expense. They stack: ```{r, eval = FALSE} #' @examples #' \donttest{ #' path <- getaca_optional("backbone", package = "yourpkg") #' if (!is.null(path)) summarise_backbone(path) #' } ``` ### In vignettes A vignette is built during `R CMD check`, so it faces the same constraint as an example, with the extra problem that its output is what users read. Three shapes work, in descending order of preference. **Bundle a small fixture.** A vignette that demonstrates the API on a 100 KB extract in `inst/extdata` runs everywhere, always produces the same output, and never depends on a download. This is the right answer whenever the point being made does not need the full resource. Most of the time it does not. A vignette showing how an input is matched, how a result is shaped, or what an argument changes needs twenty rows, not six million. Cut the extract once, commit it, and generate it from a `data-raw/` script so it can be regenerated when the upstream schema moves: ```{r, eval = FALSE} # data-raw/make-fixture.R full <- read_backbone(getaca::getaca("backbone", package = "yourpkg")) small <- head(full[full$group %in% c("a", "b"), ], 200) saveRDS(small, "inst/extdata/backbone-extract.rds", version = 3) ``` ```{r, eval = FALSE} # in the vignette backbone <- readRDS(system.file("extdata", "backbone-extract.rds", package = "yourpkg")) ``` The extract carries the same schema as the real thing, so every code path the vignette demonstrates is a path that runs against the full resource too. What it does not carry is coverage, which is why the vignette says what it is using. **Guard the section.** When the full resource genuinely is the point: ```` ```{r, eval = getaca::getaca_available("backbone", package = "yourpkg")} summarise_backbone(getaca::getaca("backbone", package = "yourpkg")) ``` ```` The chunk is skipped where the resource is absent, and the vignette still builds. Pair it with a short line of prose saying what would have appeared, so a reader of the CRAN-built version is not left with a gap. **Precompute.** For a figure that takes twenty minutes, generate it in a `data-raw/` script, commit the result, and have the vignette read the committed artefact. The `R.rsp` static vignette engine formalises this. ### Anywhere a logical reads better ```{r} getaca_available("backbone", registry = reg) ``` `getaca_available()` performs the cheap integrity check as well as the existence test, so it returns `FALSE` for a cached copy that has been truncated or replaced. It never re-hashes and never downloads, which is what makes it safe in a condition evaluated often. It also swallows resolution errors and returns `FALSE`, so a package that is not installed, or a name that is not declared, produces `FALSE` rather than an error: ```{r} getaca_available("nothing-here", package = "getaca") ``` ## Seeding a cache `getaca_prefetch()` downloads and verifies without returning anything, which is what you want in a setup step: ```{r, eval = FALSE} getaca_prefetch("backbone", package = "yourpkg") # one resource getaca_prefetch(package = "yourpkg") # everything yourpkg declares getaca_prefetch(c("backbone", "grid"), package = "yourpkg") ``` Where the cache lives is set by `GETACA_CACHE`, which is the single knob a CI job needs: ```{r, eval = FALSE} Sys.setenv(GETACA_CACHE = "/mnt/shared/getaca") ``` The cache is an ordinary directory tree with no database and no absolute paths recorded inside it, so copying it between machines works, and so does restoring it from a CI cache action. ## GitHub Actions The standard `r-lib/actions` check workflow needs two additions: a cache step and a prefetch step. ```yaml - uses: actions/cache@v4 with: path: ~/getaca-cache key: getaca-${{ runner.os }}-${{ hashFiles('inst/getaca/registry.rds') }} - name: Prefetch declared resources run: Rscript -e 'getaca::getaca_prefetch(package = "yourpkg")' env: GETACA_CACHE: ~/getaca-cache - uses: r-lib/actions/check-r-package@v2 env: GETACA_CACHE: ~/getaca-cache NOT_CRAN: true ``` Keying the cache on `inst/getaca/registry.rds` is the part worth copying. The registry file changes exactly when a declaration changes, so a new version downloads once and every subsequent job on that key restores it. Keying on the lockfile or the commit SHA instead re-downloads far more often than the data actually move. `NOT_CRAN: true` on the check step releases the clamp, so the tests that `getaca_skip_if_unavailable()` guards actually run against the seeded cache. Drop it and the job checks that your skips work, which is a weaker thing to check. For a matrix build, add the OS to the key and let each runner keep its own copy. Sharing one cache across operating systems saves nothing, because the restore is per-runner anyway. ### A separate job for the expensive path When the resource is large enough that fetching it on every push is unreasonable, split it: ```yaml on: schedule: [{cron: "0 4 * * 1"}] workflow_dispatch: jobs: full: steps: - uses: actions/cache@v4 with: path: ~/getaca-cache key: getaca-full-${{ hashFiles('inst/getaca/registry.rds') }} - run: Rscript -e 'getaca::getaca_prefetch(package = "yourpkg")' env: {GETACA_CACHE: ~/getaca-cache} - uses: r-lib/actions/check-r-package@v2 env: {GETACA_CACHE: ~/getaca-cache, NOT_CRAN: true} ``` Every push then runs the fast checks with skips, and a weekly job runs the same tests against real data. A failure in the weekly job is a signal about the data, which is usually what you want to know separately from a signal about the code. ## Testing that the guards work A guard that never fires is a guard nobody has tested. Two things are worth asserting directly. That the skip happens when the resource is absent: ```{r, eval = FALSE} test_that("the backbone test skips cleanly on a bare machine", { withr::local_envvar(c(GETACA_OFFLINE = "true")) withr::local_options(list(getaca.cache = withr::local_tempdir())) expect_false(getaca_available("backbone", package = "yourpkg")) }) ``` And that the code path a guarded call protects still behaves when the resource is present, which is what the weekly CI job is for. The failure this catches is subtle: a helper that resolves a resource at load time, or in a default argument, runs before any guard in the test body. Moving the call inside the function it belongs to is usually the whole fix. Resolution itself is testable everywhere, because it never leaves the installed package: ```{r, eval = FALSE} test_that("the shipped registry resolves the version we think it does", { reg <- registry_for("yourpkg") expect_equal(resolve_resource("backbone", registry = reg)$id$version, "2026-06") }) ``` That test runs on CRAN, catches a registry regenerated with the head pointing at the wrong record, and costs nothing. ## Reproducing a check machine locally Before submitting, run the check the way CRAN will: ```{r, eval = FALSE} withr::with_envvar( c(GETACA_CACHE = tempfile(), NOT_CRAN = ""), devtools::check() ) ``` An empty temporary cache plus an unset `NOT_CRAN` gives you a machine that has never seen your resources and refuses to fetch them. If the check passes there, it passes on a check farm. The failure this catches is an example or vignette that works on your machine because the resource happens to be cached, and produces an error on a machine where it is not. It is the single most common way a package with external data gets rejected, and it is invisible to every check run on a developer's own laptop. Run it once more with the resources present, so both halves are covered: ```{r, eval = FALSE} withr::with_envvar( c(GETACA_CACHE = "~/getaca-cache", NOT_CRAN = "true"), devtools::check() ) ``` The first run proves the package survives their machine. The second proves it does something useful on yours. ### When the check farm disagrees with your CI A package can pass everywhere you control and fail on a CRAN machine. Three causes account for most of it when external data are involved. **A leftover cache.** Your CI restored a cache the check farm does not have. The empty-cache run above is the test for this. **A different clamp.** `NOT_CRAN` set in a `.Renviron`, a `Makevars`, or a CI default releases the clamp without your noticing, and everything downloads. Check `Sys.getenv("NOT_CRAN")` in the failing environment before looking anywhere else. **Timing.** A check farm runs with limits on total check time. A test that downloads even a modest file can pass locally and time out there. Under the clamp nothing downloads, which removes the problem, but a package that released the clamp in its own tests has to keep an eye on it. The CRAN incoming checks run `--as-cran`, which enables tests the default `R CMD check` does not. `devtools::check(cran = TRUE)` matches it locally. ## What a check run actually does Every path through `getaca` under the clamp ends in one of three places, and none of them is a network call: ```{r} err <- tryCatch(getaca("backbone", registry = reg), getaca_error = function(e) e) cat(conditionMessage(err)) ``` The message is written for the person reading a check log, so it names the prefetch call and the environment variable rather than describing the internal state that produced it. ```{r} class(err) ``` `getaca_error_offline` inherits from `getaca_error_unavailable`, so a package that handles the general "cannot get it" case handles the check case for free, and a package that wants to distinguish them can. ## What to say in DESCRIPTION A package that downloads data on first use should say so where a user reads before installing. The `Description` field is the place: ``` Description: Analyses data against a large reference backbone. The backbone is downloaded on first use and cached under tools::R_user_dir(), and can be fetched ahead of time with install_backbone(). ``` Two things this earns. A reviewer sees the behaviour declared rather than discovering it, and a user on a metered connection is not surprised by a gigabyte. Naming the prefetch function also gives the answer to the question the sentence provokes. `getaca` goes in `Imports`, since the declaration is useless without it, and the recursive footprint it adds is `curl` with nothing beneath it. `getaca` carries compiled code, so a source install compiles one C file; it declares no `LinkingTo`, so nothing has to be built before it. ## CRAN checklist - [ ] no test, example or vignette downloads anything when `NOT_CRAN` is unset - [ ] tests guarded with `getaca_skip_if_unavailable()`, so skips carry a reason - [ ] examples use `getaca_optional()`, wrapped in `\donttest{}` when the resource is large - [ ] vignettes use a bundled fixture, or gate chunks on `getaca_available()` - [ ] `devtools::check()` passes against an empty `GETACA_CACHE` - [ ] the declaring package's own `Description` mentions that data are downloaded on first use ## Where to go next - `vignette("cache")` for what the cache holds and how it is managed - `vignette("failures")` for the conditions a guarded call can raise - `vignette("policies")` for the clamp's place among the settings layers ```{r, include = FALSE} options(.old_options) Sys.unsetenv(names(.old_envvars)[is.na(.old_envvars)]) .restore <- .old_envvars[!is.na(.old_envvars)] if (length(.restore)) do.call(Sys.setenv, as.list(.restore)) ```