From 7414974fd80512abe4ca290ead9c750a2fa85bff Mon Sep 17 00:00:00 2001 From: pat-s Date: Wed, 9 Sep 2026 12:38:01 +0000 Subject: [PATCH] feat(ci): verify what clients actually receive from every published slot Add a manual pipeline that installs a fixed package set from a slot under every R minor its image carries, through each URL form the website documents, and checks that each package loads. The ABI bookkeeping so far reasons about the binaries: `abi_flat_safety` records what an object's symbols say, and the index writer acts on that. None of it proves what a client receives once the CDN rewrite, the per-minor indexes and the `Path:` field have had their say. This closes that gap, and runs on the build agents so arm64 is covered on real hardware rather than skipped. - `arch` and `generic` are contractual and fail the run. - `flat` measures a client that hides its R minor; it is reported, never fatal. - CRAN is deliberately absent from `repos`, so a package missing from our index fails loudly instead of being compiled from source behind our backs. --- .crow/verify-repo-routing.yaml | 156 +++++++++++++++++++++++++++ local/verify-repo-routing.R | 190 +++++++++++++++++++++++++++++++++ 2 files changed, 346 insertions(+) create mode 100644 .crow/verify-repo-routing.yaml create mode 100644 local/verify-repo-routing.R diff --git a/.crow/verify-repo-routing.yaml b/.crow/verify-repo-routing.yaml new file mode 100644 index 0000000..07e67ec --- /dev/null +++ b/.crow/verify-repo-routing.yaml @@ -0,0 +1,156 @@ +# Install a fixed set of packages from a published slot under every R minor the +# image carries, through each URL form the website tells people to configure, +# and check that each one loads. +# +# This is the end-to-end counterpart to the ABI bookkeeping: `abi_flat_safety` +# records what the binaries say about themselves, this proves what a client +# actually receives once the CDN, the per-minor indexes and the `Path:` field +# have had their say. It runs on the build agents rather than a workstation so +# arm64 is covered on real hardware. +# +# crow pipeline create devxy/build-cran-binaries --var verify_routing=all + +variables: + # A manual pipeline creation instantiates every file in .crow/, so the default + # must match no matrix row. + verify_routing: + description: "Verify target: a specific -, 'all' for every slot, or 'none'." + options: + - none + - all + - alpine-322-amd64 + - alpine-322-arm64 + - alpine-323-amd64 + - alpine-323-arm64 + - alpine-324-amd64 + - alpine-324-arm64 + - redhat-8-amd64 + - redhat-8-arm64 + - redhat-9-amd64 + - redhat-9-arm64 + - redhat-10-amd64 + - redhat-10-arm64 + - ubuntu-2204-amd64 + - ubuntu-2204-arm64 + - ubuntu-2404-amd64 + - ubuntu-2404-arm64 + - ubuntu-2604-amd64 + - ubuntu-2604-arm64 + default: none + +when: + - event: manual + evaluate: 'verify_routing == "all" || verify_routing == "${OS}-${ARCH}"' + +skip_clone: true + +labels: + group: rpkgs-${ARCH} + +matrix: + include: + - OS: alpine-322 + ARCH: amd64 + R_VERSION: 4.5.3 + IMG: alpine:3.22 + - OS: alpine-322 + ARCH: arm64 + R_VERSION: 4.5.3 + IMG: alpine:3.22 + - OS: alpine-323 + ARCH: amd64 + R_VERSION: 4.5.3 + IMG: alpine:3.23 + - OS: alpine-323 + ARCH: arm64 + R_VERSION: 4.5.3 + IMG: alpine:3.23 + - OS: alpine-324 + ARCH: amd64 + R_VERSION: 4.5.3 + IMG: alpine:3.24 + - OS: alpine-324 + ARCH: arm64 + R_VERSION: 4.5.3 + IMG: alpine:3.24 + - OS: redhat-8 + ARCH: amd64 + R_VERSION: 4.4.3 + IMG: redhat:8 + - OS: redhat-8 + ARCH: arm64 + R_VERSION: 4.4.3 + IMG: redhat:8 + - OS: redhat-9 + ARCH: amd64 + R_VERSION: 4.4.3 + IMG: redhat:9 + - OS: redhat-9 + ARCH: arm64 + R_VERSION: 4.4.3 + IMG: redhat:9 + - OS: redhat-10 + ARCH: amd64 + R_VERSION: 4.5.3 + IMG: redhat:10 + - OS: redhat-10 + ARCH: arm64 + R_VERSION: 4.5.3 + IMG: redhat:10 + - OS: ubuntu-2204 + ARCH: amd64 + R_VERSION: 4.4.3 + IMG: ubuntu:jammy + - OS: ubuntu-2204 + ARCH: arm64 + R_VERSION: 4.4.3 + IMG: ubuntu:jammy + - OS: ubuntu-2404 + ARCH: amd64 + R_VERSION: 4.4.3 + IMG: ubuntu:noble + - OS: ubuntu-2404 + ARCH: arm64 + R_VERSION: 4.4.3 + IMG: ubuntu:noble + - OS: ubuntu-2604 + ARCH: amd64 + R_VERSION: 4.4.3 + IMG: ubuntu:resolute + - OS: ubuntu-2604 + ARCH: arm64 + R_VERSION: 4.4.3 + IMG: ubuntu:resolute + +steps: + - name: 'Verify routing serves loadable binaries' + image: reg.devxy.io/rpkgs/build-env-${IMG} + pull: true + environment: + OTEL_R_TRACES_EXPORTER: none + OTEL_R_LOGS_EXPORTER: none + OTEL_R_METRICS_EXPORTER: none + REPO_RO_TOKEN: + from_secret: REPO_RO_TOKEN + VERIFY_ARCH: ${ARCH} + commands: + - git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git . + - | + set -eu + CODENAME=$(/opt/R/$R_VERSION/bin/Rscript -e 'cat(bincraft::set_codename(NULL))') + rc=0 + for RBIN in /opt/R/[0-9]*/bin/R; do + RV=$(basename "$(dirname "$(dirname "$$RBIN")")") + for FORM in arch generic flat; do + echo "=== R $$RV via $$FORM ===" + "$(dirname "$$RBIN")/Rscript" local/verify-repo-routing.R \ + "$$VERIFY_ARCH" "$$CODENAME" "$$FORM" || rc=1 + done + done + exit $$rc + backend_options: + docker: + resources: + requests: + memory: 4Gi + cpu: 1000m diff --git a/local/verify-repo-routing.R b/local/verify-repo-routing.R new file mode 100644 index 0000000..4e984f6 --- /dev/null +++ b/local/verify-repo-routing.R @@ -0,0 +1,190 @@ +# Check that a published slot serves an installable, loadable binary for the R +# running this script, through one of the URL forms a user can configure. +# +# Rscript local/verify-repo-routing.R
+# +# One process per form on purpose: `available.packages()` caches its index in +# `tempdir()` keyed by repository URL, and the `arch` and `flat` forms share a +# URL. In a single process the second form would silently read the first form's +# index and the run would prove nothing. +# +# Forms: +# arch https://cran.rpkgs.com///latest with R's own agent +# generic https://cran.rpkgs.com/ with an agent naming the distribution +# flat the arch URL with a curl agent, so the edge cannot read an R minor +# from the request and the flat slot answers instead of a per-minor +# one +# +# `arch` and `generic` are contractual: they are what the website tells people +# to configure, so a failure there fails the run. `flat` measures what a client +# hiding its R minor is exposed to; it is reported and never fatal. + +args <- commandArgs(trailingOnly = TRUE) +if (length(args) != 3L) { + stop("usage: verify-repo-routing.R ", call. = FALSE) +} +arch <- args[[1L]] +codename <- args[[2L]] +form <- args[[3L]] + +# Compiled packages that exercise the R C API broadly, plus one pure-R package +# as a control: if `brew` fails too, the problem is the repository rather than +# the ABI. +PACKAGES <- c( + "Rcpp", + "data.table", + "stringi", + "xml2", + "curl", + "jsonlite", + "digest", + "base64enc", + "yaml", + "lobstr", + "brew" +) + +arch_url <- sprintf("https://cran.rpkgs.com/%s/%s/latest", arch, codename) + +# The agent the website's generic snippet builds, so this exercises the string +# real users send rather than an approximation of it. +generic_agent <- function() { + os_release <- readLines("/etc/os-release", warn = FALSE) + field <- function(key) { + line <- grep(sprintf("^%s=", key), os_release, value = TRUE) + if (length(line) == 0L) { + return("") + } + gsub('"', "", sub("^[^=]+=", "", line[[1L]])) + } + os <- paste(field("NAME"), field("VERSION_ID")) + codename_id <- field("VERSION_CODENAME") + if (nzchar(codename_id)) { + os <- sprintf("%s; codename=%s", os, codename_id) + } + sprintf( + "R/%s (%s) (%s)", + getRversion(), + os, + paste(R.version["platform"], R.version["arch"], R.version["os"]) + ) +} + +settings <- switch( + form, + arch = list(repo = arch_url, agent = getOption("HTTPUserAgent")), + generic = list(repo = "https://cran.rpkgs.com/", agent = generic_agent()), + flat = list(repo = arch_url, agent = "curl/8.5.0"), + stop(sprintf("unknown form '%s'", form), call. = FALSE) +) + +# No CRAN in `repos`: a package missing from our index must fail loudly rather +# than be quietly compiled from source, which would hide exactly the gap this +# script exists to find. +options(HTTPUserAgent = settings$agent, repos = c(rpkgs = settings$repo)) + +r_minor <- paste(getRversion()[1L, 1:2], collapse = ".") +rscript <- file.path(R.home("bin"), "Rscript") + +emit <- function(package, status, built = "", secs = NA_real_) { + cat( + sprintf( + "ROW\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + form, + arch, + codename, + r_minor, + package, + status, + sprintf("%s|%.1fs", built, secs) + ) + ) +} + +# `library()` in this process would abort the whole run on a bad `dyn.load()`, +# and the point of the exercise is to survive that and report it. +loads <- function(package, lib) { + status <- suppressWarnings(system2( + rscript, + c("-e", shQuote(sprintf('library("%s")', package))), + env = sprintf("R_LIBS=%s", lib), + stdout = FALSE, + stderr = FALSE + )) + identical(as.integer(status), 0L) +} + +built_field <- function(package, lib) { + desc <- file.path(lib, package, "DESCRIPTION") + if (!file.exists(desc)) { + return("") + } + value <- read.dcf(desc, fields = "Built")[[1L]] + if (is.na(value)) "" else sub(";.*$", "", value) +} + +available <- tryCatch( + rownames(available.packages()), + error = function(e) { + message(sprintf( + "index unreadable at %s: %s", + settings$repo, + conditionMessage(e) + )) + character() + } +) + +failures <- 0L +for (package in PACKAGES) { + if (!package %in% available) { + emit(package, "MISSING_FROM_INDEX") + failures <- failures + 1L + next + } + + lib <- file.path(tempdir(), sprintf("lib-%s-%s", form, package)) + dir.create(lib, showWarnings = FALSE, recursive = TRUE) + started <- Sys.time() + ok <- tryCatch( + { + # Hard dependencies come from the same repository and must be present + # for `R CMD INSTALL` to succeed, so they are installed too; Suggests are + # not, since a missing one does not stop a package loading. + install.packages( + package, + lib = lib, + dependencies = c("Depends", "Imports", "LinkingTo"), + quiet = TRUE + ) + package %in% rownames(installed.packages(lib.loc = lib)) + }, + error = function(e) FALSE + ) + secs <- as.numeric(difftime(Sys.time(), started, units = "secs")) + + if (!ok) { + emit(package, "INSTALL_FAILED", secs = secs) + failures <- failures + 1L + } else if (!loads(package, lib)) { + emit(package, "LOAD_FAILED", built_field(package, lib), secs) + failures <- failures + 1L + } else { + emit(package, "OK", built_field(package, lib), secs) + } + unlink(lib, recursive = TRUE, force = TRUE) +} + +message(sprintf( + "%s/%s R %s via %s: %d of %d packages usable", + arch, + codename, + r_minor, + form, + length(PACKAGES) - failures, + length(PACKAGES) +)) + +if (failures > 0L && !identical(form, "flat")) { + quit(status = 1L) +} -- 2.54.0