diff --git a/.crow/archive-missed-packages.yaml b/.crow/archive-missed-packages.yaml index 71e263e..71f808b 100644 --- a/.crow/archive-missed-packages.yaml +++ b/.crow/archive-missed-packages.yaml @@ -28,6 +28,14 @@ matrix: ARCH: amd64 - CODENAME: redhat-10 ARCH: arm64 + - CODENAME: alpine321 + ARCH: amd64 + - CODENAME: alpine321 + ARCH: arm64 + - CODENAME: alpine322 + ARCH: amd64 + - CODENAME: alpine322 + ARCH: arm64 - CODENAME: alpine323 ARCH: amd64 - CODENAME: alpine323 diff --git a/.crow/auto-apply-patches.yaml b/.crow/auto-apply-patches.yaml deleted file mode 100644 index 5d6e6fe..0000000 --- a/.crow/auto-apply-patches.yaml +++ /dev/null @@ -1,76 +0,0 @@ -# Auto-apply registry patches (issue #115, step 3 automation). -# Classifies `single_builds` failures and, for the top-N auto-proposable -# candidates by failure volume, writes the registry entries onto the reused -# `auto/registry-patch-proposals` branch and opens/updates a single PR. -# Nothing merges: the `trial-build-registry` pipeline is the merge gate, and a -# human reviews the PR. Novel source diffs / unknown signatures are never -# proposed. Global across platforms, so a single job -- no matrix. -# -# FORGEJO_TOKEN is used for both the branch push and opening the PR (no separate -# write-scoped secret needed). Register the `auto-apply-patches` cron in the crow -# UI, or run manually: -# crow pipeline create --branch main \ -# --var auto_apply_patches=true devxy/build-cran-binaries -# -# The gate variable is `auto_apply_patches`, named after the pipeline: a manual -# run instantiates every pipeline in `.crow/`, so one without its own gate runs -# on *any* manual trigger in this repo. This one pushes a branch and opens a PR, -# so it must stay off unless it is what was asked for. -variables: - auto_apply_patches: - description: 'Run the auto-patch proposer. Also gates this pipeline.' - options: - - 'true' - - 'false' - default: 'false' - patch_limit: - description: 'Max candidates to propose per run (top by failure volume).' - default: '10' - -when: - - event: manual - evaluate: 'auto_apply_patches == "true"' - - event: cron - cron: auto-apply-patches - -skip_clone: true - -labels: - group: rpkgs-amd64 - -steps: - - name: 'Auto-apply registry patches' - image: reg.devxy.io/rpkgs/build-env-alpine:3.24 - pull: true - environment: - PGPASS: - from_secret: PGPASS - REPO_RO_TOKEN: - from_secret: REPO_RO_TOKEN - FORGEJO_TOKEN: - from_secret: FORGEJO_TOKEN - GIT_USER: devxy-bot - GIT_EMAIL: bot@devxy.io - PATCH_LIMIT: ${patch_limit} - R_VERSION: 4.5.3 - R_LIBS_USER: /mnt/cache/R-pkgs - commands: - - git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git . - - mkdir -p /mnt/cache/R-pkgs - - rm -rf /mnt/cache/R-pkgs/00LOCK-* - - UVR_R_BIN=/opt/R/$R_VERSION/bin/R local/uvr-install.sh RPostgres httr2 jsonlite - - /opt/R/$R_VERSION/bin/Rscript local/propose-patches.R --open-pr --limit $PATCH_LIMIT - backend_options: - kubernetes: - resources: - requests: - memory: 1Gi - cpu: 2000m - limits: - memory: 2Gi - cpu: 2000m - tolerations: - - key: 'CI' - operator: 'Equal' - value: 'true' - effect: 'NoSchedule' diff --git a/.crow/backfill-abi-classification.yaml b/.crow/backfill-abi-classification.yaml deleted file mode 100644 index aeab591..0000000 --- a/.crow/backfill-abi-classification.yaml +++ /dev/null @@ -1,90 +0,0 @@ -# Populate the ABI classifier cache for packages that predate the per-minor scheme. -# -# bincraft decides whether a flat binary may be carried into a per-minor index -# from its risky-package set. That set is now the union of the per-minor tarball -# scan and the `abi_classification` cache, but the cache only holds verdicts for -# packages that passed through the build pipeline after the scheme existed. -# Everything older has no verdict, and no verdict means "carry it" - which is how -# a base64enc built under R 4.4.3 reached R 4.6 clients and died at dyn.load() -# on SETLENGTH. -# -# The verdict is computed from the package source, so it does not depend on OS, -# arch or R minor: one run populates the cache for every slot. The arch/codename -# below only choose which flat index to enumerate. -# -# Re-running is cheap. Cached verdicts are returned without touching the network, -# so a second run costs one query per package and downloads only what is still -# unclassified. -# -# crow pipeline create devxy/build-cran-binaries --var backfill_abi=run - -variables: - # A manual pipeline creation instantiates every file in .crow/, so the default - # must be a value the `when` clause rejects. - backfill_abi: - description: "Set to 'run' to classify the flat index into the ABI cache." - options: - - none - - run - default: none - BACKFILL_ARCH: - description: 'Arch of the flat index to enumerate.' - options: - - amd64 - - arm64 - default: amd64 - BACKFILL_CODENAME: - description: 'Codename of the flat index to enumerate.' - default: resolute - BACKFILL_LIMIT: - description: 'Classify at most this many packages (0 = no limit).' - default: '0' - -when: - - event: manual - evaluate: 'backfill_abi == "run"' - -skip_clone: true - -labels: - group: rpkgs-amd64 - -steps: - - name: 'Classify the flat index into the ABI cache' - image: reg.devxy.io/rpkgs/build-env-ubuntu:resolute - pull: true - environment: - OTEL_R_TRACES_EXPORTER: none - OTEL_R_LOGS_EXPORTER: none - OTEL_R_METRICS_EXPORTER: none - PGPASS: - from_secret: PGPASS - REPO_RO_TOKEN: - from_secret: REPO_RO_TOKEN - GITHUB_PAT: - from_secret: GITHUB_PAT - GIT_USER: pat-s - # Pipeline variables are substituted into the YAML, not exported, so they - # have to be bound here to be readable as env vars in the script below. - BACKFILL_ARCH: ${BACKFILL_ARCH} - BACKFILL_CODENAME: ${BACKFILL_CODENAME} - BACKFILL_LIMIT: ${BACKFILL_LIMIT} - R_LIBS_USER: /tmp/R-pkgs - commands: - - git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git . - - mkdir -p /tmp/R-pkgs - - /opt/R/4.5.3/bin/Rscript local/install-bincraft.R - - | - set -eu - LIMIT="" - if [ "$$BACKFILL_LIMIT" != "0" ]; then - LIMIT="$$BACKFILL_LIMIT" - fi - /opt/R/4.5.3/bin/Rscript local/backfill-abi-classification.R \ - "$$BACKFILL_ARCH" "$$BACKFILL_CODENAME" $$LIMIT - backend_options: - docker: - resources: - requests: - memory: 4Gi - cpu: 1000m diff --git a/.crow/backfill-flat-safety.yaml b/.crow/backfill-flat-safety.yaml deleted file mode 100644 index 0db0735..0000000 --- a/.crow/backfill-flat-safety.yaml +++ /dev/null @@ -1,176 +0,0 @@ -# Record, per flat-slot object, whether it loads under every supported R minor. -# -# `union_index_records()` keeps a carried flat record when that object is -# recorded safe and drops it otherwise. Objects uploaded before that record -# existed have no verdict, so they are dropped: a full re-index removed roughly -# 2000 records per per-minor index (16398 across the 4.4 indexes, 20076 across -# 4.5, 15532 across 4.6), each one resolving to CRAN source for clients the -# routing serves correctly. -# -# This inspects what is already in the bucket and writes the verdict, which is -# what restores that coverage. Decided by symbols rather than heuristics: a -# binary fails at dyn.load() under minor M exactly when it references a symbol M -# does not export, and the volatile set is derived by diffing the R builds in -# the image. -# -# Re-running is cheap; recorded objects are skipped. -# -# crow pipeline create devxy/build-cran-binaries --var flat_safety=all - -variables: - # A manual pipeline creation instantiates every file in .crow/, so the default - # must match no matrix row. - flat_safety: - description: "Inspect target: a specific -, 'all' for every slot, or 'none'." - options: - - none - - all - - 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 - FLAT_SAFETY_LIMIT: - description: 'Inspect at most this many objects per slot (0 = no limit).' - default: '0' - -when: - - event: manual - evaluate: 'flat_safety == "all" || flat_safety == "${OS}-${ARCH}"' - -skip_clone: true - -labels: - group: rpkgs-${ARCH} - -matrix: - include: - - 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: 'Inspect flat objects and record safety' - 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 - RED_HAT_DEV_PW: - from_secret: RED_HAT_DEV_PW - B2_S3_ACCESS_KEY: - from_secret: B2_S3_ACCESS_KEY - B2_S3_SECRET_KEY: - from_secret: B2_S3_SECRET_KEY - PGPASS: - from_secret: PGPASS - REPO_RO_TOKEN: - from_secret: REPO_RO_TOKEN - # uvr resolves bincraft's dependencies through the GitHub API, including - # the pat-s/cranlike@s3 fork. Unauthenticated it is rate limited, and the - # failure is not loud: uvr warns, falls back to the registry copy of - # cranlike, and the index write then dies on `unused argument (built = - # built)` because upstream has no such parameter. Intermittent by nature, - # since it depends on the rate-limit window. - GITHUB_PAT: - from_secret: GITHUB_PAT - GIT_USER: pat-s - R_LIBS_USER: /tmp/R-pkgs - FLAT_SAFETY_ARCH: ${ARCH} - FLAT_SAFETY_LIMIT: ${FLAT_SAFETY_LIMIT} - commands: - - git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git . - - mkdir -p /tmp/R-pkgs - - /opt/R/$R_VERSION/bin/Rscript local/install-bincraft.R - - | - set -eu - LIMIT="" - if [ "$$FLAT_SAFETY_LIMIT" != "0" ]; then - LIMIT="$$FLAT_SAFETY_LIMIT" - fi - # The codename comes from the image's /etc/os-release, exactly as the - # re-index step derives it, so it cannot drift from the matrix. - CODENAME=$(/opt/R/$R_VERSION/bin/Rscript -e 'cat(bincraft::set_codename(NULL))') - echo "slot: $$FLAT_SAFETY_ARCH/$$CODENAME" - /opt/R/$R_VERSION/bin/Rscript local/backfill-flat-safety.R \ - "$$FLAT_SAFETY_ARCH" "$$CODENAME" $$LIMIT - backend_options: - docker: - resources: - requests: - memory: 4Gi - cpu: 1000m diff --git a/.crow/build-all-versions-install-deps.yaml b/.crow/build-all-versions-install-deps.yaml index 2a6d9c2..e0cca86 100644 --- a/.crow/build-all-versions-install-deps.yaml +++ b/.crow/build-all-versions-install-deps.yaml @@ -3,33 +3,29 @@ # Variables are declared so the manual-run form exposes them (crow #1165); # they are merged with build-all-versions' identical declarations. variables: - # Gates this pipeline. A manual pipeline creation instantiates every file in - # .crow/, and a declared default is applied even when the run never passed - # this variable, so the default must be a value that matches no matrix row. target_arch: - description: 'Architecture to build, or "none" to run nothing.' + description: 'Architecture to build.' options: - - none - amd64 - arm64 - default: none + default: amd64 OS: - description: 'Base OS image name.' + description: "Base OS image name." options: - alpine - redhat - ubuntu default: alpine OS_VERSION: - description: 'OS image tag. Must match OS (alpine: 3.24; redhat: 8/9/10; ubuntu: jammy/noble).' + description: "OS image tag. Must match OS (alpine: 3.24; redhat: 8/9/10; ubuntu: jammy/noble)." options: - - '3.24' - - '8' - - '9' - - '10' - - 'jammy' - - 'noble' - default: '3.24' + - "3.24" + - "8" + - "9" + - "10" + - "jammy" + - "noble" + default: "3.24" R_VERSION: description: 'Primary R version under /opt/R.' options: @@ -70,24 +66,23 @@ steps: from_secret: B2_S3_SECRET_KEY PGPASS: from_secret: PGPASS + R_PKG_CACHE_DIR: /mnt/cache/pkgcache R_LIBS_USER: /mnt/cache/R-pkgs - # Keep uvr's downloads and extracted-package entries on the persistent - # volume instead of the container-local ~/.uvr default. - UVR_CACHE_DIR: /mnt/cache/uvr/cache - UVR_PACKAGES_DIR: /mnt/cache/uvr/packages CCACHE_DIR: /mnt/cache/ccache volumes: - ${ARCH}-binaries-r-dep-cache-${OS}-${OS_VERSION//./}:/mnt/cache commands: # one-time full wipe to fix corrupted .so files from previous failed builds # - rm -rf /mnt/cache/R-pkgs - - mkdir -p /mnt/cache/uvr/cache /mnt/cache/uvr/packages /mnt/cache/R-pkgs /mnt/cache/ccache /mnt/cache/packages + # Clear churny pkgcache metadata left by a prior crashed run (the "patched" + # repo mints a new hash per PACKAGES change -> unbounded pkgs-*.rds/patched-*). + # Keep pkg/ downloads and the stable CRAN/BioC/INLA repo dirs. + - rm -rf /mnt/cache/pkgcache/R/pkgcache/_metadata/patched-* /mnt/cache/pkgcache/R/pkgcache/_metadata/pkgs-*.rds || true + - mkdir -p /mnt/cache/pkgcache /mnt/cache/R-pkgs /mnt/cache/ccache /mnt/cache/packages - git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git . # Pin the same bincraft version the build steps use, so the precomputed # snapshot and the per-agent library stay consistent across the pipeline. - - /opt/R/$R_VERSION/bin/Rscript local/install-bincraft.R - - UVR_R_BIN=/opt/R/$R_VERSION/bin/R local/uvr-install.sh RPostgres s3fs data.table future jsonlite - - /opt/R/$R_VERSION/bin/R -q -e 'packageVersion("bincraft")' + - /opt/R/$R_VERSION/bin/R -q -e 'pak::sysreqs_db_update(); source("local/install-bincraft.R"); pak::pak(c("RPostgres", "s3fs", "data.table", "future", "jsonlite")); packageVersion("bincraft")' - /opt/R/$R_VERSION/bin/R -q -e "source('local/packages-to-build.R'); saveRDS(pkgs, '/mnt/cache/packages/pkgs_to_build.rds'); saveRDS(pkgs[r_minor_sensitive == TRUE], '/mnt/cache/packages/r_minor_sensitive_pkgs.rds'); sprintf('Precomputed %s package versions (%s r-minor-sensitive)', nrow(pkgs), nrow(pkgs[r_minor_sensitive == TRUE]))" backend_options: docker: diff --git a/.crow/build-all-versions.yaml b/.crow/build-all-versions.yaml index ce3bc09..3932811 100644 --- a/.crow/build-all-versions.yaml +++ b/.crow/build-all-versions.yaml @@ -4,44 +4,33 @@ # image and cache volume. Placement is via the group label (rpkgs-amd64/rpkgs-arm64). # Skip list lives in local/excluded-packages.json (read by local/build-all.R). variables: - # Gates this pipeline. A manual pipeline creation instantiates every file in - # .crow/, and a declared default is applied even when the run never passed - # this variable, so the default must be a value that matches no matrix row. target_arch: - description: 'Architecture to build, or "none" to run nothing.' + description: 'Architecture to build.' options: - - none - amd64 - arm64 - default: none + default: amd64 OS: - description: 'Base OS image name.' + description: "Base OS image name." options: - alpine - redhat - ubuntu default: alpine OS_VERSION: - description: 'OS image tag. Must match OS (alpine: 3.24; redhat: 8/9/10; ubuntu: jammy/noble).' + description: "OS image tag. Must match OS (alpine: 3.24; redhat: 8/9/10; ubuntu: jammy/noble)." options: - - '3.23' - - '3.24' - - '8' - - '9' - - '10' - - 'jammy' - - 'noble' - - 'resolute' - default: '3.24' + - "3.22" + - "3.23" + - "3.24" + - "8" + - "9" + - "10" + - "jammy" + - "noble" + - "resolute" + default: "3.24" R_VERSION: - # The slot's *primary* R minor: what `local/build-all.R` builds into the - # generic slot. The loop below already runs `--sensitive-only` for every - # other installed minor, so filling a non-primary minor's gap needs this - # left alone, not changed. - # - # 4.6.0 was briefly offered here (#183) and removed: selecting it for a - # slot whose generic binaries are 4.5-built would publish 4.6 binaries - # into the generic slot and break every 4.5 client. description: 'Primary R version under /opt/R.' options: - 4.5.3 @@ -58,49 +47,38 @@ labels: platform: linux/${ARCH} group: rpkgs-${ARCH} -# Empty UVR_CACHE_DIR/UVR_PACKAGES_DIR fall back to uvr's container-local -# ~/.uvr defaults; amd64 deliberately does not persist them (as with the -# pkgcache dir it replaces), arm64 does. matrix: include: - ARCH: amd64 - UVR_CACHE_DIR: '' - UVR_PACKAGES_DIR: '' + R_PKG_CACHE_DIR: '' SPLIT_INTO: 4 SPLIT_INDEX: 1 - ARCH: amd64 - UVR_CACHE_DIR: '' - UVR_PACKAGES_DIR: '' + R_PKG_CACHE_DIR: '' SPLIT_INTO: 4 SPLIT_INDEX: 2 - ARCH: amd64 - UVR_CACHE_DIR: '' - UVR_PACKAGES_DIR: '' + R_PKG_CACHE_DIR: '' SPLIT_INTO: 4 SPLIT_INDEX: 3 - ARCH: amd64 - UVR_CACHE_DIR: '' - UVR_PACKAGES_DIR: '' + R_PKG_CACHE_DIR: '' SPLIT_INTO: 4 SPLIT_INDEX: 4 - ARCH: arm64 - UVR_CACHE_DIR: /mnt/cache/uvr/cache - UVR_PACKAGES_DIR: /mnt/cache/uvr/packages + R_PKG_CACHE_DIR: /mnt/cache/pkgcache SPLIT_INTO: 4 SPLIT_INDEX: 1 - ARCH: arm64 - UVR_CACHE_DIR: /mnt/cache/uvr/cache - UVR_PACKAGES_DIR: /mnt/cache/uvr/packages + R_PKG_CACHE_DIR: /mnt/cache/pkgcache SPLIT_INTO: 4 SPLIT_INDEX: 2 - ARCH: arm64 - UVR_CACHE_DIR: /mnt/cache/uvr/cache - UVR_PACKAGES_DIR: /mnt/cache/uvr/packages + R_PKG_CACHE_DIR: /mnt/cache/pkgcache SPLIT_INTO: 4 SPLIT_INDEX: 3 - ARCH: arm64 - UVR_CACHE_DIR: /mnt/cache/uvr/cache - UVR_PACKAGES_DIR: /mnt/cache/uvr/packages + R_PKG_CACHE_DIR: /mnt/cache/pkgcache SPLIT_INTO: 4 SPLIT_INDEX: 4 @@ -129,10 +107,8 @@ steps: from_secret: GITHUB_PAT # normal env vars GIT_USER: pat-s - # set the location of uvr's caches, which persist the R package - # dependencies needed to install the packages themselves - UVR_CACHE_DIR: ${UVR_CACHE_DIR} - UVR_PACKAGES_DIR: ${UVR_PACKAGES_DIR} + # set the location of the 'pkgcache' cache dir which persists the R package dependencies needed to install the packages themselves + R_PKG_CACHE_DIR: ${R_PKG_CACHE_DIR} R_LIBS_USER: /mnt/cache/R-pkgs CCACHE_DIR: /mnt/cache/ccache NCPUS: 2 @@ -140,7 +116,12 @@ steps: - ${ARCH}-binaries-r-dep-cache-${OS}-${OS_VERSION//./}:/mnt/cache commands: - git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git . - - mkdir -p /mnt/cache/uvr/cache /mnt/cache/uvr/packages /mnt/cache/R-pkgs /mnt/cache/ccache /mnt/cache/packages + # Clear churny pkgcache metadata left by a prior crashed run (the "patched" + # repo mints a new hash per PACKAGES change -> unbounded pkgs-*.rds/patched-*). + # Keep pkg/ downloads and the stable CRAN/BioC/INLA repo dirs. Within-run + # growth is bounded separately by trim_pkgcache_metadata() in build-all.R. + - rm -rf /mnt/cache/pkgcache/R/pkgcache/_metadata/patched-* /mnt/cache/pkgcache/R/pkgcache/_metadata/pkgs-*.rds || true + - mkdir -p /mnt/cache/pkgcache /mnt/cache/R-pkgs /mnt/cache/ccache /mnt/cache/packages # The primary pass must not rely on build-all-versions-install-deps having # run on *this* agent: depends_on only orders the steps, but the cache # volume is per-agent, so a job landing on an agent where install-deps did @@ -149,11 +130,7 @@ steps: # install). Pin bincraft here, exactly like the R-minor pass below. - rm -rf /mnt/cache/R-pkgs/00LOCK-* - /opt/R/$R_VERSION/bin/Rscript local/install-bincraft.R - # The dependency-prep job may run on another agent whose cache volume is - # not visible here. Install the packages needed to recompute the snapshot - # and query build metadata in every build job. - - UVR_R_BIN=/opt/R/$R_VERSION/bin/R local/uvr-install.sh RPostgres s3fs data.table future jsonlite - - XVFB=$(command -v xwfb-run 2>/dev/null || command -v xvfb-run); XVFB_ARGS="-a"; if command -v xwfb-run >/dev/null 2>&1; then dnf install -y -q weston 2>/dev/null; XVFB_ARGS="-c weston"; fi + - XVFB=$(command -v xwfb-run 2>/dev/null || command -v xvfb-run); XVFB_ARGS=""; if command -v xwfb-run >/dev/null 2>&1; then dnf install -y -q weston 2>/dev/null; XVFB_ARGS="-c weston"; fi - $XVFB $XVFB_ARGS -n $SPLIT_INDEX -- /opt/R/$R_VERSION/bin/Rscript local/build-all.R $SPLIT_INTO $SPLIT_INDEX $NCPUS 2>&1 - | PRIMARY_MINOR=$(echo "$R_VERSION" | cut -d. -f1-2) @@ -165,10 +142,7 @@ steps: LIB="/mnt/cache/R-pkgs-$RMINOR" mkdir -p "$LIB" R_LIBS_USER="$LIB" "$(dirname "$RBIN")/Rscript" local/install-bincraft.R || true - R_LIBS_USER="$LIB" UVR_R_BIN="$RBIN" local/uvr-install.sh RPostgres || true R_LIBS_USER="$LIB" $XVFB $XVFB_ARGS -n $SPLIT_INDEX -- "$(dirname "$RBIN")/Rscript" local/build-all.R --sensitive-only $SPLIT_INTO $SPLIT_INDEX $NCPUS 2>&1 || true - # Also build current packages whose required dependency tree needs a newer R. - R_LIBS_USER="$LIB" $XVFB $XVFB_ARGS -n $SPLIT_INDEX -- "$(dirname "$RBIN")/Rscript" local/build-newer-r.R "$R_VERSION" $SPLIT_INTO $SPLIT_INDEX done # archive missed packages; first arg is the codename (e.g. "alpine324"), # derived via bincraft like the upload step, not paste(OS, OS_VERSION). diff --git a/.crow/process-updates.yaml b/.crow/process-updates.yaml index 531a47f..3da41fd 100644 --- a/.crow/process-updates.yaml +++ b/.crow/process-updates.yaml @@ -3,18 +3,16 @@ # Routing is preserved 1:1: # - cron: each existing `process-cran-updates--` cron fires only # its matching matrix row (via the per-row `cron:` name filter). -# - manual: pick a target from the `process_cran_updates` dropdown; -# "all" fans out every os/arch as parallel matrix workflows. +# - manual: pick a target from the `process_cran_updates` dropdown +# ("all" = every os/arch). # Arch placement is handled by the group label (rpkgs-amd64, rpkgs-arm64). variables: - # Gates this pipeline. A manual pipeline creation instantiates every file in - # .crow/, and a declared default is applied even when the run never passed - # this variable, so the default must be a value that matches no matrix row. process_cran_updates: - description: "Manual run target: a specific -, 'all' to run every os/arch in parallel, or 'none' to run nothing." + description: "Manual run target: a specific -, or 'all' for every os/arch." options: - - none - all + - alpine-322-amd64 + - alpine-322-arm64 - alpine-323-amd64 - alpine-323-arm64 - alpine-324-amd64 @@ -31,7 +29,7 @@ variables: - ubuntu-2404-arm64 - ubuntu-2604-amd64 - ubuntu-2604-arm64 - default: none + default: all when: - event: cron @@ -46,102 +44,114 @@ labels: matrix: include: + - OS: alpine-322 + ARCH: amd64 + R_VERSION: 4.5.3 + IMG: alpine:3.24 + OS_ID: alpine322 + PROCESS_NEW: "FALSE" + - OS: alpine-322 + ARCH: arm64 + R_VERSION: 4.5.3 + IMG: alpine:3.24 + OS_ID: alpine322 + PROCESS_NEW: "FALSE" - OS: alpine-323 ARCH: amd64 R_VERSION: 4.5.3 - IMG: alpine:3.23 + IMG: alpine:3.24 OS_ID: alpine323 - PROCESS_NEW: 'FALSE' + PROCESS_NEW: "FALSE" - OS: alpine-323 ARCH: arm64 R_VERSION: 4.5.3 - IMG: alpine:3.23 + IMG: alpine:3.24 OS_ID: alpine323 - PROCESS_NEW: 'FALSE' + PROCESS_NEW: "FALSE" - OS: alpine-324 ARCH: amd64 R_VERSION: 4.5.3 IMG: alpine:3.24 OS_ID: alpine324 - PROCESS_NEW: 'FALSE' + PROCESS_NEW: "FALSE" - OS: alpine-324 ARCH: arm64 R_VERSION: 4.5.3 IMG: alpine:3.24 OS_ID: alpine324 - PROCESS_NEW: 'FALSE' + PROCESS_NEW: "FALSE" - OS: redhat-8 ARCH: amd64 R_VERSION: 4.4.3 IMG: redhat:8 OS_ID: rhel8 - PROCESS_NEW: 'TRUE' + PROCESS_NEW: "TRUE" - OS: redhat-8 ARCH: arm64 R_VERSION: 4.4.3 IMG: redhat:8 OS_ID: rhel8 - PROCESS_NEW: 'TRUE' + PROCESS_NEW: "TRUE" - OS: redhat-9 ARCH: amd64 R_VERSION: 4.4.3 IMG: redhat:9 OS_ID: rhel9 - PROCESS_NEW: 'TRUE' + PROCESS_NEW: "TRUE" - OS: redhat-9 ARCH: arm64 R_VERSION: 4.4.3 IMG: redhat:9 OS_ID: rhel9 - PROCESS_NEW: 'TRUE' + PROCESS_NEW: "TRUE" - OS: redhat-10 ARCH: amd64 R_VERSION: 4.5.3 IMG: redhat:10 OS_ID: rhel10 - PROCESS_NEW: 'TRUE' + PROCESS_NEW: "TRUE" - OS: redhat-10 ARCH: arm64 R_VERSION: 4.5.3 IMG: redhat:10 OS_ID: rhel10 - PROCESS_NEW: 'TRUE' + PROCESS_NEW: "TRUE" - OS: ubuntu-2204 ARCH: amd64 R_VERSION: 4.4.3 IMG: ubuntu:jammy OS_ID: jammy - PROCESS_NEW: 'TRUE' + PROCESS_NEW: "TRUE" - OS: ubuntu-2204 ARCH: arm64 R_VERSION: 4.4.3 IMG: ubuntu:jammy OS_ID: jammy - PROCESS_NEW: 'TRUE' + PROCESS_NEW: "TRUE" - OS: ubuntu-2404 ARCH: amd64 R_VERSION: 4.4.3 IMG: ubuntu:noble OS_ID: noble - PROCESS_NEW: 'TRUE' + PROCESS_NEW: "TRUE" - OS: ubuntu-2404 ARCH: arm64 R_VERSION: 4.4.3 IMG: ubuntu:noble OS_ID: noble - PROCESS_NEW: 'TRUE' + PROCESS_NEW: "TRUE" - OS: ubuntu-2604 ARCH: amd64 R_VERSION: 4.5.3 IMG: ubuntu:resolute OS_ID: resolute - PROCESS_NEW: 'TRUE' + PROCESS_NEW: "TRUE" - OS: ubuntu-2604 ARCH: arm64 R_VERSION: 4.5.3 IMG: ubuntu:resolute OS_ID: resolute - PROCESS_NEW: 'TRUE' + PROCESS_NEW: "TRUE" steps: - name: 'Processing Updates' @@ -172,10 +182,8 @@ steps: NTFY_AUTH: TRUE NTFY_PASSWORD: from_secret: ntfy_token - # set the location of uvr's caches, which persist the R package - # dependencies needed to install the packages themselves - UVR_CACHE_DIR: /mnt/cache/uvr/cache - UVR_PACKAGES_DIR: /mnt/cache/uvr/packages + # set the location of the 'pkgcache' cache dir which persists the R package dependencies needed to install the packages themselves + R_PKG_CACHE_DIR: /mnt/cache/pkgcache R_LIBS_USER: /mnt/cache/R-pkgs R_VERSION: ${R_VERSION} CCACHE_DIR: /mnt/cache/ccache @@ -186,13 +194,12 @@ steps: INTERVAL: lubridate::interval(lubridate::today() - 6, lubridate::today() - 3) commands: - git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git . - - rm -rf /mnt/cache/R-pkgs/00LOCK-* /mnt/cache/R-pkgs/bincraft - - mkdir -p /mnt/cache/uvr/cache /mnt/cache/uvr/packages /mnt/cache/R-pkgs /mnt/cache/ccache /mnt/cache/packages + - rm -rf /mnt/cache/R-pkgs/00LOCK-* /mnt/cache/R-pkgs/bincraft /mnt/cache/R-pkgs/pkgcache /mnt/cache/pkgcache/R/pkgcache - /opt/R/$R_VERSION/bin/Rscript local/install-bincraft.R - - UVR_R_BIN=/opt/R/$R_VERSION/bin/R local/uvr-install.sh RPostgres - /opt/R/$R_VERSION/bin/R -q -e 'packageVersion("bincraft")' + - mkdir -p /mnt/cache/pkgcache /mnt/cache/R-pkgs /mnt/cache/ccache /mnt/cache/packages # rhel-10 ships xwfb-run (Xwayland) instead of xvfb-run; prefer it and start weston, else fall back to xvfb-run - - XVFB=$(command -v xwfb-run 2>/dev/null || command -v xvfb-run); XVFB_ARGS="-a"; if command -v xwfb-run >/dev/null 2>&1; then dnf install -y -q weston 2>/dev/null; XVFB_ARGS="-c weston"; fi + - XVFB=$(command -v xwfb-run 2>/dev/null || command -v xvfb-run); XVFB_ARGS=""; if command -v xwfb-run >/dev/null 2>&1; then dnf install -y -q weston 2>/dev/null; XVFB_ARGS="-c weston"; fi # options(future.globals.onReference = NULL): for some reason s3fs::file_delete() throws 'Error: Detected a non-exportable reference ('externalptr') in one of the globals ('FUN' of class 'function') used in the future expression' otherwise - $XVFB $XVFB_ARGS -- /opt/R/$R_VERSION/bin/R -q -e "options(crayon.enabled = TRUE, Ncpus = 4, future.globals.onReference = 'error', repos = structure(c(getOption('repos'),INLA='https://inla.r-inla-download.org/R/stable'))); progressr::handlers('cli'); progressr::handlers(global = TRUE); options(future.globals.onReference = NULL); bincraft::process_cran_updates(interval = $INTERVAL, platform = '${OS}', process_updated = TRUE, process_new = ${PROCESS_NEW}, process_removed = TRUE, patches = 'local/patches', r_minor_detection = 'classifier',s3_endpoint = 'https://s3.eu-central-003.backblazeb2.com', s3_region = 'eu-central-003', s3_bucket = 'devxy-rpkgs-binaries', s3_access_key_id = Sys.getenv('B2_S3_ACCESS_KEY'), s3_secret_access_key = Sys.getenv('B2_S3_SECRET_KEY'), metadata_db_host = 'r-binaries.devxy.io', metadata_db_name = 'build_metadata', metadata_db_table = 'single_builds', metadata_db_user = 'rpkgs', metadata_db_password = Sys.getenv('PGPASS'), metadata_db_sslmode = 'require', metadata_db_port = 15432, archive = TRUE, upload = TRUE, store_build_metadata = TRUE)" - | @@ -205,10 +212,7 @@ steps: LIB="/mnt/cache/R-pkgs-$RMINOR" mkdir -p "$LIB" R_LIBS_USER="$LIB" "$(dirname "$RBIN")/Rscript" local/install-bincraft.R || true - R_LIBS_USER="$LIB" UVR_R_BIN="$RBIN" local/uvr-install.sh RPostgres || true R_LIBS_USER="$LIB" $XVFB $XVFB_ARGS -- "$(dirname "$RBIN")/R" -q -e "options(crayon.enabled = TRUE, Ncpus = 4, future.globals.onReference = NULL); bincraft::process_cran_updates(interval = $INTERVAL, platform = '${OS}', process_updated = TRUE, process_new = FALSE, process_removed = FALSE, patches = 'local/patches', r_minor_detection = 'classifier',r_minor_sensitive_only = TRUE, s3_endpoint = 'https://s3.eu-central-003.backblazeb2.com', s3_region = 'eu-central-003', s3_bucket = 'devxy-rpkgs-binaries', s3_access_key_id = Sys.getenv('B2_S3_ACCESS_KEY'), s3_secret_access_key = Sys.getenv('B2_S3_SECRET_KEY'), metadata_db_host = 'r-binaries.devxy.io', metadata_db_name = 'build_metadata', metadata_db_table = 'single_builds', metadata_db_user = 'rpkgs', metadata_db_password = Sys.getenv('PGPASS'), metadata_db_sslmode = 'require', metadata_db_port = 15432, archive = TRUE, upload = TRUE, store_build_metadata = TRUE)" || true - # Also build current packages whose required dependency tree needs a newer R. - R_LIBS_USER="$LIB" $XVFB $XVFB_ARGS -- "$(dirname "$RBIN")/Rscript" local/build-newer-r.R "$R_VERSION" done - /opt/R/$R_VERSION/bin/R -q -e 'library(bincraft); upload_package_index(codename = "${OS_ID}", s3_endpoint = "https://s3.eu-central-003.backblazeb2.com", s3_region = "eu-central-003", s3_bucket = "devxy-rpkgs-binaries", s3_access_key_id = Sys.getenv("B2_S3_ACCESS_KEY"), s3_secret_access_key = Sys.getenv("B2_S3_SECRET_KEY"))' - | diff --git a/.crow/prune-stale-flat-binaries.yaml b/.crow/prune-stale-flat-binaries.yaml deleted file mode 100644 index 79ca003..0000000 --- a/.crow/prune-stale-flat-binaries.yaml +++ /dev/null @@ -1,242 +0,0 @@ -# Remove flat-slot binaries recorded unable to load under some R minor, then -# re-index. -# -# The list comes from `abi_flat_safety`, so it names the objects the binaries -# themselves report as broken, not the ones a source heuristic suspects. That -# distinction is the whole cost of the operation: the heuristic calls 93% of -# compiled packages risky and would delete roughly 3000 objects per slot to fix -# about 230. -# -# `upload_single_binary()` has written a non-portable package only to the -# per-minor slot since v5.3.0, so this is a one-time cleanup of what was -# uploaded before that. The flat slot is what every client that cannot be -# routed by R minor receives, and such a binary was built under whichever R was -# primary at the time, so it dies at load under any minor that dropped a symbol -# it uses: -# -# base64enc.so: undefined symbol: SETLENGTH -# -# Deleting the flat object does not create a hole. The source fallback -# republishes the CRAN source under the same key, so the package stays -# installable everywhere and compiles against the client's own R, while routed -# clients keep getting the per-minor binary. -# -# Dry run unless PRUNE_APPLY is 'yes'. Start with an explicit package list: -# -# crow pipeline create devxy/build-cran-binaries \ -# --var prune_flat=ubuntu-2604-amd64 \ -# --var PRUNE_PACKAGES="base64enc lobstr" \ -# --var PRUNE_APPLY=yes - -variables: - # A manual pipeline creation instantiates every file in .crow/, so the default - # must match no matrix row. - prune_flat: - description: "Prune target: a specific -, 'all' for every slot, or 'none'." - options: - - none - - all - - 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 - PRUNE_PACKAGES: - description: 'Space-separated package names. Empty means every package the classifier cache calls risky.' - default: '' - PRUNE_APPLY: - description: "Set to 'yes' to actually delete. Anything else is a dry run." - options: - - 'no' - - 'yes' - default: 'no' - -when: - - event: manual - evaluate: 'prune_flat == "all" || prune_flat == "${OS}-${ARCH}"' - -skip_clone: true - -labels: - group: rpkgs-${ARCH} - -matrix: - include: - - OS: alpine-323 - ARCH: amd64 - CODENAME: alpine323 - IMG: alpine:3.23 - - OS: alpine-323 - ARCH: arm64 - CODENAME: alpine323 - IMG: alpine:3.23 - - OS: alpine-324 - ARCH: amd64 - CODENAME: alpine324 - IMG: alpine:3.24 - - OS: alpine-324 - ARCH: arm64 - CODENAME: alpine324 - IMG: alpine:3.24 - - OS: redhat-8 - ARCH: amd64 - CODENAME: rhel8 - IMG: redhat:8 - - OS: redhat-8 - ARCH: arm64 - CODENAME: rhel8 - IMG: redhat:8 - - OS: redhat-9 - ARCH: amd64 - CODENAME: rhel9 - IMG: redhat:9 - - OS: redhat-9 - ARCH: arm64 - CODENAME: rhel9 - IMG: redhat:9 - - OS: redhat-10 - ARCH: amd64 - CODENAME: rhel10 - IMG: redhat:10 - - OS: redhat-10 - ARCH: arm64 - CODENAME: rhel10 - IMG: redhat:10 - - OS: ubuntu-2204 - ARCH: amd64 - CODENAME: jammy - IMG: ubuntu:jammy - - OS: ubuntu-2204 - ARCH: arm64 - CODENAME: jammy - IMG: ubuntu:jammy - - OS: ubuntu-2404 - ARCH: amd64 - CODENAME: noble - IMG: ubuntu:noble - - OS: ubuntu-2404 - ARCH: arm64 - CODENAME: noble - IMG: ubuntu:noble - - OS: ubuntu-2604 - ARCH: amd64 - CODENAME: resolute - IMG: ubuntu:resolute - - OS: ubuntu-2604 - ARCH: arm64 - CODENAME: resolute - IMG: ubuntu:resolute - -steps: - - name: 'Prune stale flat 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 - B2_S3_ACCESS_KEY: - from_secret: B2_S3_ACCESS_KEY - B2_S3_SECRET_KEY: - from_secret: B2_S3_SECRET_KEY - PGPASS: - from_secret: PGPASS - REPO_RO_TOKEN: - from_secret: REPO_RO_TOKEN - GIT_USER: pat-s - R_LIBS_USER: /tmp/R-pkgs - # Pipeline variables are substituted into the YAML, not exported, so they - # have to be bound here to be readable as env vars in the script below. - PRUNE_ARCH: ${ARCH} - PRUNE_CODENAME: ${CODENAME} - PRUNE_PACKAGES: ${PRUNE_PACKAGES} - PRUNE_APPLY: ${PRUNE_APPLY} - commands: - - git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git . - - mkdir -p /tmp/R-pkgs - - /opt/R/4.5.3/bin/Rscript local/install-bincraft.R - - | - set -eu - APPLY="" - if [ "$$PRUNE_APPLY" = "yes" ]; then - APPLY="--apply" - fi - /opt/R/4.5.3/bin/Rscript local/prune-stale-flat-binaries.R \ - "$$PRUNE_ARCH" "$$PRUNE_CODENAME" $$APPLY $$PRUNE_PACKAGES - backend_options: - docker: - resources: - requests: - memory: 3Gi - cpu: 1000m - - # Deleting objects without re-indexing is worse than leaving them: the - # published index keeps naming files that no longer exist, so every client - # gets a 404 instead of a working source fallback. The re-index is what - # republishes them as CRAN source, so it belongs in the same run. - - name: 'Re-index the slot' - 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 - B2_S3_ACCESS_KEY: - from_secret: B2_S3_ACCESS_KEY - B2_S3_SECRET_KEY: - from_secret: B2_S3_SECRET_KEY - PGPASS: - from_secret: PGPASS - R_LIBS_USER: /tmp/R-pkgs - PRUNE_APPLY: ${PRUNE_APPLY} - commands: - - | - set -eu - if [ "$$PRUNE_APPLY" != "yes" ]; then - echo "Dry run: nothing was deleted, so there is nothing to re-index." - exit 0 - fi - /opt/R/4.5.3/bin/R -q -e 'library(bincraft); upload_package_index(s3_endpoint = "https://s3.eu-central-003.backblazeb2.com", s3_region = "eu-central-003", s3_bucket = "devxy-rpkgs-binaries", s3_access_key_id = Sys.getenv("B2_S3_ACCESS_KEY"), s3_secret_access_key = Sys.getenv("B2_S3_SECRET_KEY"))' - for RBIN in /opt/R/[0-9]*/bin/R; do - RMINOR=$(basename "$(dirname "$(dirname "$RBIN")")" | cut -d. -f1-2) - /opt/R/4.5.3/bin/R -q -e "library(bincraft); upload_package_index(r_minor = '$RMINOR', s3_endpoint = 'https://s3.eu-central-003.backblazeb2.com', s3_region = 'eu-central-003', s3_bucket = 'devxy-rpkgs-binaries', s3_access_key_id = Sys.getenv('B2_S3_ACCESS_KEY'), s3_secret_access_key = Sys.getenv('B2_S3_SECRET_KEY'))" || true - done - backend_options: - docker: - resources: - requests: - memory: 3Gi - cpu: 1000m - - # The edge caches indexes for ~370 days, so without this the deletion is - # invisible to clients and they keep being handed the removed binary. - - name: 'Purge CDN cache' - image: reg.devxy.io/docker.io/library/alpine:3.24 - environment: - BUNNYNET_API_KEY: - from_secret: BUNNYNET_API_KEY - # Same zones as reindex.yaml: the production pull zone id, the alliance - # hostname and the staging zone, which shares the B2 origin. - BUNNY_PULLZONES: '3857050 cran.allianceswisspass.devxy.io cran-rpkgs-test.b-cdn.net' - PRUNE_APPLY: ${PRUNE_APPLY} - commands: - - | - set -eu - if [ "$$PRUNE_APPLY" != "yes" ]; then - echo "Dry run: nothing changed at the origin, so no purge is needed." - exit 0 - fi - apk add --no-cache -q bash curl jq - bash scripts/purge_cdn_zone.sh "$BUNNYNET_API_KEY" $BUNNY_PULLZONES diff --git a/.crow/rebuild-abi-gap.yaml b/.crow/rebuild-abi-gap.yaml deleted file mode 100644 index 71ade22..0000000 --- a/.crow/rebuild-abi-gap.yaml +++ /dev/null @@ -1,214 +0,0 @@ -# Rebuild specific packages into a per-R-minor slot. -# -# The per-minor scheme only ever covered packages that passed through the build -# pipeline after it existed. A package built into the flat slot before that, and -# never updated on CRAN since, is never reclassified and never gets a per-minor -# copy - so the union index serves its flat binary to every minor. When that -# binary references a symbol the client's R no longer exports, the install fails -# at dyn.load(): -# -# base64enc.so: undefined symbol: SETLENGTH -# -# base64enc calls SETLENGTH, its flat binary was built under R 4.4.3, and R 4.6 -# does not export SETLENGTH. There was no way to rebuild one package into one -# minor from CI, only `just rebuild` from a workstation with the S3 and Postgres -# credentials to hand. This adds that path. -# -# `--sensitive-only` is deliberately NOT passed. That flag gates on the source -# classifier, which misses Rcpp: the repair therefore skipped Rcpp entirely, -# leaving its 4.5-built flat binary as the only copy and breaking every R 4.4 -# client on the slot. -# -# Since bincraft v5.3.0 the destination is decided by the built binary's symbols -# in `upload_single_binary()`, so building unconditionally is safe: a package -# that needs a per-minor slot lands there, and one that does not lands in the -# flat slot. Skipping before the build is what removed that decision. -# -# Trigger with the slot and an explicit package list: -# -# crow pipeline create devxy/build-cran-binaries \ -# --var rebuild_abi_gap=ubuntu-2604-amd64 \ -# --var ABI_PACKAGES="base64enc@0.1-6 lobstr@1.2.1" \ -# --var ABI_R_MINOR=4.6 - -variables: - # A manual pipeline creation instantiates every file in .crow/, so the - # default must match no matrix row. - rebuild_abi_gap: - description: "Rebuild target: a specific -, 'all' for every slot, or 'none'." - options: - - none - - all - - 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 - ABI_PACKAGES: - description: 'Space-separated package@version list, e.g. "base64enc@0.1-6 lobstr@1.2.1".' - default: '' - ABI_R_MINOR: - description: 'The R minor to build into.' - options: - - '4.4' - - '4.5' - - '4.6' - default: '4.6' - -when: - - event: manual - evaluate: 'rebuild_abi_gap == "all" || rebuild_abi_gap == "${OS}-${ARCH}"' - -skip_clone: true - -labels: - group: rpkgs-${ARCH} - -matrix: - include: - - 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: 'Rebuild packages into the per-minor slot' - 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 - RED_HAT_DEV_PW: - from_secret: RED_HAT_DEV_PW - B2_S3_ACCESS_KEY: - from_secret: B2_S3_ACCESS_KEY - B2_S3_SECRET_KEY: - from_secret: B2_S3_SECRET_KEY - PGPASS: - from_secret: PGPASS - REPO_RO_TOKEN: - from_secret: REPO_RO_TOKEN - GITHUB_PAT: - from_secret: GITHUB_PAT - GIT_USER: pat-s - # Pipeline variables are substituted into the YAML, not exported, so they - # have to be bound here to be readable as env vars in the script below. - ABI_PACKAGES: ${ABI_PACKAGES} - ABI_R_MINOR: ${ABI_R_MINOR} - UVR_CACHE_DIR: /tmp/uvr/cache - UVR_PACKAGES_DIR: /tmp/uvr/packages - CCACHE_DIR: /tmp/ccache - NCPUS: 2 - commands: - - git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git . - - mkdir -p $UVR_CACHE_DIR $UVR_PACKAGES_DIR $CCACHE_DIR - - | - set -eu - if [ -z "$$ABI_PACKAGES" ]; then - echo "ABI_PACKAGES is empty; nothing to rebuild." >&2 - exit 1 - fi - RBIN=$(ls -d /opt/R/$${ABI_R_MINOR}*/bin 2>/dev/null | head -1) - if [ -z "$$RBIN" ]; then - echo "R $$ABI_R_MINOR is not installed in this image." >&2 - exit 1 - fi - # Container-local library: this workflow mounts no shared cache volume, - # so it cannot disturb the build pipeline's per-agent caches. - LIB="/tmp/R-pkgs-$$ABI_R_MINOR" - mkdir -p "$$LIB" - export R_LIBS_USER="$$LIB" - rm -rf "$$LIB"/00LOCK-* - "$$RBIN/Rscript" local/install-bincraft.R - rc=0 - for spec in $$ABI_PACKAGES; do - pkg=$${spec%@*} - ver=$${spec#*@} - if [ "$$pkg" = "$$ver" ]; then - echo "malformed spec '$$spec'; expected package@version" >&2 - rc=1 - continue - fi - echo "=== rebuilding $$pkg $$ver into $$ABI_R_MINOR ===" - "$$RBIN/Rscript" local/build-one.R "$$pkg" "$$ver" || rc=1 - done - exit $$rc - backend_options: - docker: - resources: - requests: - memory: 5Gi - cpu: 1000m diff --git a/.crow/reindex.yaml b/.crow/reindex.yaml deleted file mode 100644 index 803d9a5..0000000 --- a/.crow/reindex.yaml +++ /dev/null @@ -1,198 +0,0 @@ -# Re-index every slot without rebuilding anything. -# -# `weekly-rebuild-reindex` exists to run after `weekly-rebuild-missing`, so it -# depends on that workflow and shares its gate: triggering it manually also -# starts hours of package rebuilds. That is the wrong tool when only the index -# needs regenerating - after a bincraft release that changes how the index is -# written, for instance, where the objects in the bucket are already correct -# and only `PACKAGES*` is stale. -# -# This workflow does the index half on its own. It installs the latest bincraft -# release, republishes the generic and per-R-minor indexes for each slot, and -# purges the edge. No package is built. -# -# Trigger with the `reindex` variable set to `all` or to a single -# `-`, e.g. -# -# crow pipeline create devxy/build-cran-binaries --var reindex=all - -variables: - # A manual pipeline creation instantiates every file in .crow/, so the - # default must match no matrix row. - reindex: - description: "Re-index target: a specific -, 'all' for every slot, or 'none'." - options: - - none - - all - - 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: 'reindex == "all" || reindex == "${OS}-${ARCH}"' - -skip_clone: true - -labels: - group: rpkgs-${ARCH} - -matrix: - include: - - 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: 'Re-index the slot' - 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 - RED_HAT_DEV_PW: - from_secret: RED_HAT_DEV_PW - B2_S3_ACCESS_KEY: - from_secret: B2_S3_ACCESS_KEY - B2_S3_SECRET_KEY: - from_secret: B2_S3_SECRET_KEY - # The index build reads the ABI classifier cache to decide which packages - # may be carried from the flat slot into a per-minor one. Without the - # password that query fails, and it fails soft, so the run still succeeds - # while quietly using an empty risky set: exactly the state the cache was - # added to fix. It logs one connection error per slot. - PGPASS: - from_secret: PGPASS - REPO_RO_TOKEN: - from_secret: REPO_RO_TOKEN - # uvr resolves bincraft's dependencies through the GitHub API, including - # the pat-s/cranlike@s3 fork. Unauthenticated it is rate limited, and the - # failure is not loud: uvr warns, falls back to the registry copy of - # cranlike, and the index write then dies on `unused argument (built = - # built)` because upstream has no such parameter. Intermittent by nature, - # since it depends on the rate-limit window. - GITHUB_PAT: - from_secret: GITHUB_PAT - GIT_USER: pat-s - R_LIBS_USER: /mnt/cache/R-pkgs - R_VERSION: ${R_VERSION} - PLATFORM: ${OS} - ARCH: ${ARCH} - commands: - - git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git . - - mkdir -p /mnt/cache/R-pkgs - - rm -rf /mnt/cache/R-pkgs/00LOCK-* - - /opt/R/$R_VERSION/bin/Rscript local/install-bincraft.R - # The codename is detected from the image's /etc/os-release. - - /opt/R/$R_VERSION/bin/R -q -e 'library(bincraft); upload_package_index(s3_endpoint = "https://s3.eu-central-003.backblazeb2.com", s3_region = "eu-central-003", s3_bucket = "devxy-rpkgs-binaries", s3_access_key_id = Sys.getenv("B2_S3_ACCESS_KEY"), s3_secret_access_key = Sys.getenv("B2_S3_SECRET_KEY"))' - - | - for RBIN in /opt/R/[0-9]*/bin/R; do - RMINOR=$(basename "$(dirname "$(dirname "$RBIN")")" | cut -d. -f1-2) - /opt/R/$R_VERSION/bin/R -q -e "library(bincraft); upload_package_index(r_minor = '$RMINOR', s3_endpoint = 'https://s3.eu-central-003.backblazeb2.com', s3_region = 'eu-central-003', s3_bucket = 'devxy-rpkgs-binaries', s3_access_key_id = Sys.getenv('B2_S3_ACCESS_KEY'), s3_secret_access_key = Sys.getenv('B2_S3_SECRET_KEY'))" || true - done - - - name: Purge CDN cache - image: reg.devxy.io/docker.io/library/alpine:3.24 - environment: - OTEL_R_TRACES_EXPORTER: none - OTEL_R_LOGS_EXPORTER: none - OTEL_R_METRICS_EXPORTER: none - BUNNYNET_API_KEY: - from_secret: BUNNYNET_API_KEY - # cran.rpkgs.com and cran.allianceswisspass.devxy.io are on separate - # Bunny pull zones, so both must be purged after the shared origin changes. - # The staging zone is listed too. It shares the B2 origin, so an index - # it still holds is a stale copy of the same object, and its - # cache_expiration_time is the same ~370 days: without a purge here it - # serves pre-reindex indexes indefinitely and any verification run - # against it measures the past. - BUNNY_PULLZONES: '3857050 cran.allianceswisspass.devxy.io cran-rpkgs-test.b-cdn.net' - commands: - - apk add --no-cache -q bash curl jq - # Crow carries the checkout from the re-index step into this step. - - bash scripts/purge_cdn_zone.sh "$BUNNYNET_API_KEY" $BUNNY_PULLZONES - # Runs on every row rather than on one designated slot: a cron fires only - # its own slot's row, so gating on a named slot would leave every other - # slot unpurged. A manual "all" run therefore purges the zone 18 times, - # which is a cheap API call and rare. - # - # Run it even when the re-index above failed: the objects were still - # replaced, and a stale edge is exactly what keeps them hidden. - when: - - status: [success, failure] diff --git a/.crow/repair-built-stamp.yaml b/.crow/repair-built-stamp.yaml deleted file mode 100644 index cc09586..0000000 --- a/.crow/repair-built-stamp.yaml +++ /dev/null @@ -1,109 +0,0 @@ -### Manual repair of a slot whose PACKAGES index advertises a broken `Built` -### stamp (e.g. `Built: R 4.5.0; NA; ...`). -# -# uvr matches the stamp's platform triple plus R minor to decide binary vs -# source, so an unusable triple turns a whole slot source-only. See -# local/repair-built-stamp.R for why this patches PACKAGES.db in place instead -# of forcing a full reparse. -# -# Run with `dry_run: true` first: it reports how many entries are broken per -# slot and changes nothing. Pick the R version the slot should advertise, which -# is the R_VERSION its entry in .crow/process-updates.yaml uses. -# -# The gate variable is `repair_built_stamp`, not `target_arch`: `target_arch` is -# what build-all-versions and build-all-versions-install-deps gate on, so a -# manual run passing it would start a full rebuild alongside this repair. Every -# pipeline here gates on a variable named after itself for exactly that reason. -# -# crow pipeline create --branch main \ -# --var repair_built_stamp=arm64 --var OS=alpine --var OS_VERSION=3.23 \ -# --var R_VERSION=4.5.3 --var dry_run=true devxy/build-cran-binaries -variables: - # Gates this pipeline. A manual pipeline creation instantiates every file in - # .crow/, and a declared default is applied even when the run never passed - # this variable, so the default must be a value that matches no matrix row. - repair_built_stamp: - description: 'Architecture of the slot to repair, or "none" to run nothing.' - options: - - none - - amd64 - - arm64 - default: none - OS: - description: 'Base OS image name.' - options: - - alpine - - redhat - - ubuntu - default: alpine - OS_VERSION: - description: 'OS image tag. Must match OS (alpine: 3.23/3.24; redhat: 8/9/10; ubuntu: jammy/noble/resolute).' - options: - - '3.23' - - '3.24' - - '8' - - '9' - - '10' - - 'jammy' - - 'noble' - - 'resolute' - default: '3.23' - R_VERSION: - description: 'R version whose stamp the slot should advertise.' - options: - - 4.5.3 - - 4.4.3 - default: 4.5.3 - dry_run: - description: 'Report what would change without writing anything.' - options: - - 'true' - - 'false' - default: 'true' - -when: - - event: manual - evaluate: 'repair_built_stamp == "${ARCH}"' - -skip_clone: true - -labels: - platform: linux/${ARCH} - group: rpkgs-${ARCH} - -matrix: - include: - - ARCH: amd64 - - ARCH: arm64 - -steps: - - name: 'Repair Built stamp' - image: 'reg.devxy.io/rpkgs/build-env-${OS}:${OS_VERSION}' - pull: true - environment: - B2_S3_ACCESS_KEY: - from_secret: B2_S3_ACCESS_KEY - B2_S3_SECRET_KEY: - from_secret: B2_S3_SECRET_KEY - REPO_RO_TOKEN: - from_secret: REPO_RO_TOKEN - GIT_USER: pat-s - commands: - - git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git . - - /opt/R/$R_VERSION/bin/Rscript local/install-bincraft.R - - /opt/R/$R_VERSION/bin/R -q -e 'packageVersion("bincraft")' - - | - if [ "$dry_run" = "false" ]; then - /opt/R/$R_VERSION/bin/Rscript local/repair-built-stamp.R "$ARCH" --apply - else - /opt/R/$R_VERSION/bin/Rscript local/repair-built-stamp.R "$ARCH" - fi - backend_options: - docker: - resources: - requests: - memory: 2Gi - cpu: 1000m - limits: - memory: 8Gi - cpu: 2000m diff --git a/.crow/repair-unsafe-flat-objects.yaml b/.crow/repair-unsafe-flat-objects.yaml deleted file mode 100644 index e0625c0..0000000 --- a/.crow/repair-unsafe-flat-objects.yaml +++ /dev/null @@ -1,173 +0,0 @@ -# Rebuild every flat object recorded as unable to load under some R minor. -# -# The list comes from `abi_flat_safety`, written by the flat-safety backfill -# from the binaries themselves, so it names exactly the objects that fail at -# dyn.load() somewhere rather than everything a source heuristic suspects. The -# heuristic calls 93% of compiled packages risky; the binaries say 4.3%. -# -# Each object is rebuilt under every R minor installed in the image, with no -# `--sensitive-only` gate. Since bincraft v5.3.0 the destination is decided by -# the built binary's symbols in `upload_single_binary()`, so a package needing a -# per-minor slot lands there and a portable one lands in the flat slot. Gating -# on the source classifier is what let Rcpp slip through and break every R 4.4 -# client on the slot. -# -# crow pipeline create devxy/build-cran-binaries --var repair_unsafe=all - -variables: - # A manual pipeline creation instantiates every file in .crow/, so the default - # must match no matrix row. - repair_unsafe: - description: "Repair target: a specific -, 'all' for every slot, or 'none'." - options: - - none - - all - - 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: 'repair_unsafe == "all" || repair_unsafe == "${OS}-${ARCH}"' - -skip_clone: true - -labels: - group: rpkgs-${ARCH} - -matrix: - include: - - 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: 'Rebuild objects recorded unsafe' - 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 - RED_HAT_DEV_PW: - from_secret: RED_HAT_DEV_PW - B2_S3_ACCESS_KEY: - from_secret: B2_S3_ACCESS_KEY - B2_S3_SECRET_KEY: - from_secret: B2_S3_SECRET_KEY - PGPASS: - from_secret: PGPASS - REPO_RO_TOKEN: - from_secret: REPO_RO_TOKEN - GITHUB_PAT: - from_secret: GITHUB_PAT - GIT_USER: pat-s - R_LIBS_USER: /tmp/R-pkgs - NCPUS: 2 - REPAIR_ARCH: ${ARCH} - commands: - - git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git . - - mkdir -p /tmp/R-pkgs - - /opt/R/$R_VERSION/bin/Rscript local/install-bincraft.R - - | - set -eu - CODENAME=$(/opt/R/$R_VERSION/bin/Rscript -e 'cat(bincraft::set_codename(NULL))') - /opt/R/$R_VERSION/bin/Rscript local/list-unsafe-flat-objects.R \ - "$$REPAIR_ARCH" "$$CODENAME" > /tmp/unsafe.txt - echo "objects to rebuild: $(wc -l < /tmp/unsafe.txt)" - rc=0 - for RBIN in /opt/R/[0-9]*/bin/R; do - RV=$(basename "$(dirname "$(dirname "$$RBIN")")") - RMINOR=$(echo "$$RV" | cut -d. -f1-2) - echo "=== rebuilding under R $$RV ===" - LIB="/tmp/R-pkgs-$$RMINOR" - mkdir -p "$$LIB" - R_LIBS_USER="$$LIB" "$(dirname "$$RBIN")/Rscript" local/install-bincraft.R || true - while read -r pkg ver; do - [ -z "$$pkg" ] && continue - R_LIBS_USER="$$LIB" "$(dirname "$$RBIN")/Rscript" \ - local/build-one.R "$$pkg" "$$ver" || rc=1 - done < /tmp/unsafe.txt - done - exit $$rc - backend_options: - docker: - resources: - requests: - memory: 5Gi - cpu: 1000m diff --git a/.crow/retire-slot.yaml b/.crow/retire-slot.yaml deleted file mode 100644 index 1b62ba7..0000000 --- a/.crow/retire-slot.yaml +++ /dev/null @@ -1,72 +0,0 @@ -# Delete a retired slot's objects from the bucket. -# -# The config half is a commit, so it runs on a workstation: -# just retire-slot --apply -# -# This half needs the B2 credentials, which only exist in CI. -# `local/retire-slot.R` refuses any slot still listed in `local.rpkgs_slots`, -# so this cannot delete a live slot. Both architectures go together. -# -# Dry run unless RETIRE_APPLY is 'yes': -# -# crow pipeline create devxy/build-cran-binaries --branch main \ -# --var retire_slot=alpine322 --var RETIRE_APPLY=yes - -variables: - # A manual pipeline creation instantiates every file in .crow/, so the - # default must be a value the script never accepts. - retire_slot: - description: "Codename of the slot to retire (e.g. alpine322), or 'none'." - default: none - RETIRE_APPLY: - description: "Set to 'yes' to delete. Anything else reports and changes nothing." - options: - - 'no' - - 'yes' - default: 'no' - -when: - - event: manual - evaluate: 'retire_slot != "none"' - -skip_clone: true - -labels: - group: rpkgs-amd64 - -steps: - - name: 'Delete the retired slot from the bucket' - image: reg.devxy.io/rpkgs/build-env-alpine:3.24 - pull: true - environment: - OTEL_R_TRACES_EXPORTER: none - OTEL_R_LOGS_EXPORTER: none - OTEL_R_METRICS_EXPORTER: none - B2_S3_ACCESS_KEY: - from_secret: B2_S3_ACCESS_KEY - B2_S3_SECRET_KEY: - from_secret: B2_S3_SECRET_KEY - REPO_RO_TOKEN: - from_secret: REPO_RO_TOKEN - R_LIBS_USER: /tmp/R-pkgs - # Pipeline variables are substituted into the YAML, not exported, so they - # have to be bound here to be readable as env vars in the script below. - RETIRE_SLOT: ${retire_slot} - RETIRE_APPLY: ${RETIRE_APPLY} - commands: - - git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git . - - mkdir -p /tmp/R-pkgs - - /opt/R/4.5.3/bin/Rscript local/install-bincraft.R - - | - set -eu - APPLY="" - if [ "$$RETIRE_APPLY" = "yes" ]; then - APPLY="--apply" - fi - /opt/R/4.5.3/bin/Rscript local/retire-slot.R "$$RETIRE_SLOT" --assets-only $$APPLY - backend_options: - docker: - resources: - requests: - memory: 2Gi - cpu: 1000m diff --git a/.crow/trial-build-registry.yaml b/.crow/trial-build-registry.yaml deleted file mode 100644 index 7826d3e..0000000 --- a/.crow/trial-build-registry.yaml +++ /dev/null @@ -1,152 +0,0 @@ -# Merge gate for the auto-patch PR (issue #115, step 3). -# For each platform, trial-builds every registry entry the auto-patch branch -# ADDS (vs main) in that platform's own `reg.devxy.io/rpkgs/build-env-*` image, -# with the registry applied. A row with no new entries for its platform is a -# fast no-op. The pipeline is green only if every new entry builds, so it gates -# the PR before merge. Nothing is uploaded/archived/recorded. -# -# The repo uses no `pull_request` triggers, so this runs manually against the -# branch (or on a cron); point it at the auto-patch branch via `patch_branch`: -# crow pipeline create --branch main --var trial_build_registry=true \ -# --var patch_branch=auto/registry-patch-proposals devxy/build-cran-binaries -# -# The gate variable is `trial_build_registry`, named after the pipeline: a -# manual run instantiates every pipeline in `.crow/`, so one without its own -# gate runs on *any* manual trigger in this repo. This one starts a build per -# matrix row on both arches, which is far too expensive to fire by accident. -variables: - trial_build_registry: - description: 'Trial-build the branch new registry entries. Also gates this pipeline.' - options: - - 'true' - - 'false' - default: 'false' - patch_branch: - description: 'Branch whose new registry entries to trial-build.' - default: auto/registry-patch-proposals - -when: - - event: manual - evaluate: 'trial_build_registry == "true"' - - event: cron - cron: trial-build-registry - -skip_clone: true - -labels: - group: rpkgs-${ARCH} - -matrix: - include: - - 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.5.3 - IMG: ubuntu:resolute - - OS: ubuntu-2604 - ARCH: arm64 - R_VERSION: 4.5.3 - IMG: ubuntu:resolute - -steps: - - name: 'Trial-build new registry entries' - image: reg.devxy.io/rpkgs/build-env-${IMG} - pull: true - environment: - B2_S3_ACCESS_KEY: - from_secret: B2_S3_ACCESS_KEY - B2_S3_SECRET_KEY: - from_secret: B2_S3_SECRET_KEY - REPO_RO_TOKEN: - from_secret: REPO_RO_TOKEN - GITHUB_PAT: - from_secret: GITHUB_PAT - PLATFORM: ${OS} - ARCH: ${ARCH} - R_VERSION: ${R_VERSION} - R_LIBS_USER: /mnt/cache/R-pkgs - # Surface the real compiler error when an isolated patched build fails, - # instead of bincraft's opaque "System command 'R' failed" (needs bincraft - # with BINCRAFT_VERBOSE_PATCH_BUILD support; harmless on older versions). - BINCRAFT_VERBOSE_PATCH_BUILD: 'TRUE' - commands: - # Clone main, then check out the auto-patch branch if it exists. When the - # proposer had no candidates it never (re)creates that branch, so a missing - # branch means "nothing to verify" -- no-op cleanly instead of failing the - # clone. - - git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git . - - git fetch -q origin main - - 'if git ls-remote --exit-code --heads origin ${patch_branch} >/dev/null 2>&1; then git fetch -q origin ${patch_branch} && git checkout -q FETCH_HEAD; else echo "No ${patch_branch} branch; no pending auto-patch proposals to verify."; exit 0; fi' - - mkdir -p /mnt/cache/R-pkgs - - rm -rf /mnt/cache/R-pkgs/00LOCK-* - - /opt/R/$R_VERSION/bin/Rscript local/install-bincraft.R - - /opt/R/$R_VERSION/bin/Rscript local/trial-build-registry.R origin/main - backend_options: - kubernetes: - resources: - requests: - memory: 2Gi - cpu: 2000m - limits: - memory: 4Gi - cpu: 2000m - tolerations: - - key: 'CI' - operator: 'Equal' - value: 'true' - effect: 'NoSchedule' diff --git a/.crow/verify-repo-routing.yaml b/.crow/verify-repo-routing.yaml deleted file mode 100644 index b20c993..0000000 --- a/.crow/verify-repo-routing.yaml +++ /dev/null @@ -1,146 +0,0 @@ -# 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-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-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/.crow/weekly-audit-missing.yaml b/.crow/weekly-audit-missing.yaml index 2796a47..6ff71e3 100644 --- a/.crow/weekly-audit-missing.yaml +++ b/.crow/weekly-audit-missing.yaml @@ -3,22 +3,20 @@ # Routing is preserved 1:1: # - cron: each existing `weekly-audit-missing--` cron fires only # its matching matrix row (via the per-row `cron:` name filter). -# - manual: pick a target from the `weekly_audit_missing` dropdown; -# "all" fans out every os/arch as parallel matrix workflows. +# - manual: pick a target from the `weekly_audit_missing` dropdown +# ("all" = every os/arch). # Arch placement is via the group label (rpkgs-amd64, rpkgs-arm64). variables: - # Gates this pipeline. A manual pipeline creation instantiates every file in - # .crow/, and a declared default is applied even when the run never passed - # this variable, so the default must be a value that matches no matrix row. weekly_audit_missing: - description: "Manual run target: a specific -, 'all' to run every os/arch in parallel, or 'none' to run nothing." + description: "Manual run target: a specific -, or 'all' for every os/arch." options: - - none - all + - alpine-321-amd64 + - alpine-321-arm64 + - 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 @@ -29,9 +27,7 @@ variables: - ubuntu-2204-arm64 - ubuntu-2404-amd64 - ubuntu-2404-arm64 - - ubuntu-2604-amd64 - - ubuntu-2604-arm64 - default: none + default: all when: - event: cron @@ -46,19 +42,27 @@ labels: matrix: include: - - 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 + - OS: alpine-321 ARCH: amd64 R_VERSION: 4.5.3 IMG: alpine:3.24 - - OS: alpine-324 + - OS: alpine-321 + ARCH: arm64 + R_VERSION: 4.5.3 + IMG: alpine:3.24 + - OS: alpine-322 + ARCH: amd64 + R_VERSION: 4.5.3 + IMG: alpine:3.24 + - OS: alpine-322 + ARCH: arm64 + R_VERSION: 4.5.3 + IMG: alpine:3.24 + - OS: alpine-323 + ARCH: amd64 + R_VERSION: 4.5.3 + IMG: alpine:3.24 + - OS: alpine-323 ARCH: arm64 R_VERSION: 4.5.3 IMG: alpine:3.24 @@ -102,14 +106,6 @@ matrix: ARCH: arm64 R_VERSION: 4.4.3 IMG: ubuntu:noble - - OS: ubuntu-2604 - ARCH: amd64 - R_VERSION: 4.5.3 - IMG: ubuntu:resolute - - OS: ubuntu-2604 - ARCH: arm64 - R_VERSION: 4.5.3 - IMG: ubuntu:resolute steps: - name: 'Audit missing binaries' @@ -136,8 +132,7 @@ steps: - git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git . - mkdir -p /mnt/cache/packages /mnt/cache/R-pkgs - rm -rf /mnt/cache/R-pkgs/00LOCK-* - - /opt/R/$R_VERSION/bin/Rscript local/install-bincraft.R - - UVR_R_BIN=/opt/R/$R_VERSION/bin/R local/uvr-install.sh RPostgres httr2 jsonlite + - /opt/R/$R_VERSION/bin/R -q -e 'pak::pak(c("git::https://codefloe.com/rpkgs/bincraft.git", "httr2", "jsonlite"))' - /opt/R/$R_VERSION/bin/R -q -e 'source("local/weekly-missing-binaries-audit.R")' backend_options: docker: diff --git a/.crow/weekly-patch-proposals.yaml b/.crow/weekly-patch-proposals.yaml index 7ea6c97..8fa45df 100644 --- a/.crow/weekly-patch-proposals.yaml +++ b/.crow/weekly-patch-proposals.yaml @@ -1,33 +1,18 @@ # Weekly failure-triage proposals (issue #115, steps 3 + 4). # Classifies the recorded `single_builds` failures and: -# 1. posts/updates a Forgejo issue with the auto-proposable registry entries -# (human reviews, trial-builds, and opens the PR -- nothing merges), -# 2. posts/updates a second Forgejo issue with the classifier's blind spots -# (unclassified failures = candidates for new signatures, and groups -# blocked on a dependency), and prints the feedback-loop metrics to the log. +# 1. posts/updates a Forgejo tracking issue with the auto-proposable registry +# entries (human reviews, trial-builds, and opens the PR -- nothing merges), and +# 2. prints the feedback-loop metrics (signature hit rate, proposed-vs-merged, +# retirement candidates) to the run log. # Global across platforms (the classifier groups over all of single_builds), so -# a single job -- no matrix. Clones read-only; the only writes are the two -# Forgejo issues via FORGEJO_TOKEN. +# a single job -- no matrix. Clones read-only; the only write is the Forgejo +# issue via FORGEJO_TOKEN. # -# Run manually with: -# crow pipeline create --branch main \ -# --var weekly_patch_proposals=true devxy/build-cran-binaries -# -# The gate variable is `weekly_patch_proposals`, named after the pipeline: a -# manual run instantiates every pipeline in `.crow/`, so one without its own -# gate runs on *any* manual trigger in this repo. This one posts and edits -# Forgejo issues, so an unrelated manual run must not fire it. -variables: - weekly_patch_proposals: - description: 'Run the weekly failure triage. Also gates this pipeline.' - options: - - 'true' - - 'false' - default: 'false' - +# Register the cron in the crow UI as `weekly-patch-proposals`, or run manually: +# woodpecker-cli pipeline create --var task=weekly-patch-proposals --branch=main 7 when: - event: manual - evaluate: 'weekly_patch_proposals == "true"' + evaluate: 'task == "weekly-patch-proposals"' - event: cron cron: weekly-patch-proposals @@ -53,9 +38,9 @@ steps: - git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git . - mkdir -p /mnt/cache/R-pkgs - rm -rf /mnt/cache/R-pkgs/00LOCK-* - - UVR_R_BIN=/opt/R/$R_VERSION/bin/R local/uvr-install.sh RPostgres httr2 jsonlite + - /opt/R/$R_VERSION/bin/R -q -e 'pak::pak(c("httr2", "jsonlite"))' - /opt/R/$R_VERSION/bin/Rscript local/propose-patches.R --open-issue - - /opt/R/$R_VERSION/bin/Rscript local/proposal-tracking.R --open-issue + - /opt/R/$R_VERSION/bin/Rscript local/proposal-tracking.R backend_options: kubernetes: resources: diff --git a/.crow/weekly-rebuild-missing.yaml b/.crow/weekly-rebuild-missing.yaml index a93a7bd..934c518 100644 --- a/.crow/weekly-rebuild-missing.yaml +++ b/.crow/weekly-rebuild-missing.yaml @@ -1,34 +1,21 @@ # Consolidated weekly-rebuild-missing pipeline (all platforms, both arches). -# Three matrix rows per OS/arch, one per shard of that slot's rebuild list. +# One matrix row per OS/arch replaces the former per-platform files. # Routing is preserved 1:1: # - cron: each existing `weekly-rebuild-missing--` cron fires only -# its matching matrix rows (via the per-row `cron:` name filter), -# which is now all three shards of that slot. -# - manual: pick a target from the `weekly_rebuild_missing` dropdown; -# "all" fans out every os/arch and shard as parallel matrix -# workflows, while a single - runs its three shards. +# its matching matrix row (via the per-row `cron:` name filter). +# - manual: `weekly_rebuild_missing` dropdown, default "all" (matches the +# previous bare manual trigger that ran every os/arch); pick a +# single - to run just one. # Arch placement is handled by the group label (rpkgs-amd64, rpkgs-arm64). -# -# The shard picks up its own slice and re-derives what is still outstanding -# from the bucket, so a restart resumes rather than replaying; see -# local/rebuild-missing.R. -# -# Re-indexing and the CDN purge deliberately do NOT live here. Three shards -# writing one slot's PACKAGES concurrently would race, so they moved to -# .crow/weekly-rebuild-reindex.yaml, which depends on this pipeline. variables: - # Gates this pipeline. A manual pipeline creation instantiates every file in - # .crow/, and a declared default is applied even when the run never passed - # this variable, so the default must be a value that matches no matrix row. weekly_rebuild_missing: - description: "Manual run target: a specific -, 'all' to run every os/arch in parallel, or 'none' to run nothing." + description: "Manual run target: a specific -, or 'all' for every os/arch." 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 @@ -39,9 +26,7 @@ variables: - ubuntu-2204-arm64 - ubuntu-2404-amd64 - ubuntu-2404-arm64 - - ubuntu-2604-amd64 - - ubuntu-2604-arm64 - default: none + default: all when: - event: cron @@ -56,294 +41,62 @@ labels: matrix: include: - - OS: alpine-323 - ARCH: amd64 - R_VERSION: 4.5.3 - IMG: alpine:3.23 - SPLIT_INTO: 3 - SPLIT_INDEX: 1 - - OS: alpine-323 - ARCH: amd64 - R_VERSION: 4.5.3 - IMG: alpine:3.23 - SPLIT_INTO: 3 - SPLIT_INDEX: 2 - - OS: alpine-323 - ARCH: amd64 - R_VERSION: 4.5.3 - IMG: alpine:3.23 - SPLIT_INTO: 3 - SPLIT_INDEX: 3 - - OS: alpine-323 - ARCH: arm64 - R_VERSION: 4.5.3 - IMG: alpine:3.23 - SPLIT_INTO: 3 - SPLIT_INDEX: 1 - - OS: alpine-323 - ARCH: arm64 - R_VERSION: 4.5.3 - IMG: alpine:3.23 - SPLIT_INTO: 3 - SPLIT_INDEX: 2 - - OS: alpine-323 - ARCH: arm64 - R_VERSION: 4.5.3 - IMG: alpine:3.23 - SPLIT_INTO: 3 - SPLIT_INDEX: 3 - - OS: alpine-324 + - OS: alpine-322 ARCH: amd64 R_VERSION: 4.5.3 IMG: alpine:3.24 - SPLIT_INTO: 3 - SPLIT_INDEX: 1 - - OS: alpine-324 + - OS: alpine-322 + ARCH: arm64 + R_VERSION: 4.5.3 + IMG: alpine:3.24 + - OS: alpine-323 ARCH: amd64 R_VERSION: 4.5.3 IMG: alpine:3.24 - SPLIT_INTO: 3 - SPLIT_INDEX: 2 - - OS: alpine-324 - ARCH: amd64 - R_VERSION: 4.5.3 - IMG: alpine:3.24 - SPLIT_INTO: 3 - SPLIT_INDEX: 3 - - OS: alpine-324 + - OS: alpine-323 ARCH: arm64 R_VERSION: 4.5.3 IMG: alpine:3.24 - SPLIT_INTO: 3 - SPLIT_INDEX: 1 - - OS: alpine-324 - ARCH: arm64 - R_VERSION: 4.5.3 - IMG: alpine:3.24 - SPLIT_INTO: 3 - SPLIT_INDEX: 2 - - OS: alpine-324 - ARCH: arm64 - R_VERSION: 4.5.3 - IMG: alpine:3.24 - SPLIT_INTO: 3 - SPLIT_INDEX: 3 - OS: redhat-8 ARCH: amd64 R_VERSION: 4.4.3 IMG: redhat:8 - SPLIT_INTO: 3 - SPLIT_INDEX: 1 - - OS: redhat-8 - ARCH: amd64 - R_VERSION: 4.4.3 - IMG: redhat:8 - SPLIT_INTO: 3 - SPLIT_INDEX: 2 - - OS: redhat-8 - ARCH: amd64 - R_VERSION: 4.4.3 - IMG: redhat:8 - SPLIT_INTO: 3 - SPLIT_INDEX: 3 - OS: redhat-8 ARCH: arm64 R_VERSION: 4.4.3 IMG: redhat:8 - SPLIT_INTO: 3 - SPLIT_INDEX: 1 - - OS: redhat-8 - ARCH: arm64 - R_VERSION: 4.4.3 - IMG: redhat:8 - SPLIT_INTO: 3 - SPLIT_INDEX: 2 - - OS: redhat-8 - ARCH: arm64 - R_VERSION: 4.4.3 - IMG: redhat:8 - SPLIT_INTO: 3 - SPLIT_INDEX: 3 - OS: redhat-9 ARCH: amd64 R_VERSION: 4.4.3 IMG: redhat:9 - SPLIT_INTO: 3 - SPLIT_INDEX: 1 - - OS: redhat-9 - ARCH: amd64 - R_VERSION: 4.4.3 - IMG: redhat:9 - SPLIT_INTO: 3 - SPLIT_INDEX: 2 - - OS: redhat-9 - ARCH: amd64 - R_VERSION: 4.4.3 - IMG: redhat:9 - SPLIT_INTO: 3 - SPLIT_INDEX: 3 - OS: redhat-9 ARCH: arm64 R_VERSION: 4.4.3 IMG: redhat:9 - SPLIT_INTO: 3 - SPLIT_INDEX: 1 - - OS: redhat-9 - ARCH: arm64 - R_VERSION: 4.4.3 - IMG: redhat:9 - SPLIT_INTO: 3 - SPLIT_INDEX: 2 - - OS: redhat-9 - ARCH: arm64 - R_VERSION: 4.4.3 - IMG: redhat:9 - SPLIT_INTO: 3 - SPLIT_INDEX: 3 - OS: redhat-10 ARCH: amd64 R_VERSION: 4.5.3 IMG: redhat:10 - SPLIT_INTO: 3 - SPLIT_INDEX: 1 - - OS: redhat-10 - ARCH: amd64 - R_VERSION: 4.5.3 - IMG: redhat:10 - SPLIT_INTO: 3 - SPLIT_INDEX: 2 - - OS: redhat-10 - ARCH: amd64 - R_VERSION: 4.5.3 - IMG: redhat:10 - SPLIT_INTO: 3 - SPLIT_INDEX: 3 - OS: redhat-10 ARCH: arm64 R_VERSION: 4.5.3 IMG: redhat:10 - SPLIT_INTO: 3 - SPLIT_INDEX: 1 - - OS: redhat-10 - ARCH: arm64 - R_VERSION: 4.5.3 - IMG: redhat:10 - SPLIT_INTO: 3 - SPLIT_INDEX: 2 - - OS: redhat-10 - ARCH: arm64 - R_VERSION: 4.5.3 - IMG: redhat:10 - SPLIT_INTO: 3 - SPLIT_INDEX: 3 - OS: ubuntu-2204 ARCH: amd64 R_VERSION: 4.4.3 IMG: ubuntu:jammy - SPLIT_INTO: 3 - SPLIT_INDEX: 1 - - OS: ubuntu-2204 - ARCH: amd64 - R_VERSION: 4.4.3 - IMG: ubuntu:jammy - SPLIT_INTO: 3 - SPLIT_INDEX: 2 - - OS: ubuntu-2204 - ARCH: amd64 - R_VERSION: 4.4.3 - IMG: ubuntu:jammy - SPLIT_INTO: 3 - SPLIT_INDEX: 3 - OS: ubuntu-2204 ARCH: arm64 R_VERSION: 4.4.3 IMG: ubuntu:jammy - SPLIT_INTO: 3 - SPLIT_INDEX: 1 - - OS: ubuntu-2204 - ARCH: arm64 - R_VERSION: 4.4.3 - IMG: ubuntu:jammy - SPLIT_INTO: 3 - SPLIT_INDEX: 2 - - OS: ubuntu-2204 - ARCH: arm64 - R_VERSION: 4.4.3 - IMG: ubuntu:jammy - SPLIT_INTO: 3 - SPLIT_INDEX: 3 - OS: ubuntu-2404 ARCH: amd64 R_VERSION: 4.4.3 IMG: ubuntu:noble - SPLIT_INTO: 3 - SPLIT_INDEX: 1 - - OS: ubuntu-2404 - ARCH: amd64 - R_VERSION: 4.4.3 - IMG: ubuntu:noble - SPLIT_INTO: 3 - SPLIT_INDEX: 2 - - OS: ubuntu-2404 - ARCH: amd64 - R_VERSION: 4.4.3 - IMG: ubuntu:noble - SPLIT_INTO: 3 - SPLIT_INDEX: 3 - OS: ubuntu-2404 ARCH: arm64 R_VERSION: 4.4.3 IMG: ubuntu:noble - SPLIT_INTO: 3 - SPLIT_INDEX: 1 - - OS: ubuntu-2404 - ARCH: arm64 - R_VERSION: 4.4.3 - IMG: ubuntu:noble - SPLIT_INTO: 3 - SPLIT_INDEX: 2 - - OS: ubuntu-2404 - ARCH: arm64 - R_VERSION: 4.4.3 - IMG: ubuntu:noble - SPLIT_INTO: 3 - SPLIT_INDEX: 3 - - OS: ubuntu-2604 - ARCH: amd64 - R_VERSION: 4.4.3 - IMG: ubuntu:resolute - SPLIT_INTO: 3 - SPLIT_INDEX: 1 - - OS: ubuntu-2604 - ARCH: amd64 - R_VERSION: 4.4.3 - IMG: ubuntu:resolute - SPLIT_INTO: 3 - SPLIT_INDEX: 2 - - OS: ubuntu-2604 - ARCH: amd64 - R_VERSION: 4.4.3 - IMG: ubuntu:resolute - SPLIT_INTO: 3 - SPLIT_INDEX: 3 - - OS: ubuntu-2604 - ARCH: arm64 - R_VERSION: 4.4.3 - IMG: ubuntu:resolute - SPLIT_INTO: 3 - SPLIT_INDEX: 1 - - OS: ubuntu-2604 - ARCH: arm64 - R_VERSION: 4.4.3 - IMG: ubuntu:resolute - SPLIT_INTO: 3 - SPLIT_INDEX: 2 - - OS: ubuntu-2604 - ARCH: arm64 - R_VERSION: 4.4.3 - IMG: ubuntu:resolute - SPLIT_INTO: 3 - SPLIT_INDEX: 3 steps: - name: 'Rebuild missing binaries' @@ -368,30 +121,23 @@ steps: FORGEJO_TOKEN: from_secret: FORGEJO_TOKEN GIT_USER: pat-s - UVR_CACHE_DIR: /mnt/cache/uvr/cache - UVR_PACKAGES_DIR: /mnt/cache/uvr/packages + R_PKG_CACHE_DIR: /mnt/cache/pkgcache R_LIBS_USER: /mnt/cache/R-pkgs R_VERSION: ${R_VERSION} CCACHE_DIR: /mnt/cache/ccache PLATFORM: ${OS} ARCH: ${ARCH} NCPUS: 2 - SPLIT_INTO: ${SPLIT_INTO} - SPLIT_INDEX: ${SPLIT_INDEX} - # Wall clock after which the shard stops cleanly instead of having to be - # killed. A kill matches neither `success` nor `failure`, so it would skip - # the dependent re-index and leave rebuilt binaries behind a stale edge. - REBUILD_BUDGET_HOURS: 20 commands: - git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git . - - mkdir -p /mnt/cache/uvr/cache /mnt/cache/uvr/packages /mnt/cache/R-pkgs /mnt/cache/ccache /mnt/cache/packages + - mkdir -p /mnt/cache/pkgcache /mnt/cache/R-pkgs /mnt/cache/ccache /mnt/cache/packages - rm -rf /mnt/cache/R-pkgs/00LOCK-* - /opt/R/$R_VERSION/bin/Rscript local/install-bincraft.R - /opt/R/$R_VERSION/bin/R -q -e 'packageVersion("bincraft")' - - XVFB=$(command -v xwfb-run 2>/dev/null || command -v xvfb-run); XVFB_ARGS="-a"; if command -v xwfb-run >/dev/null 2>&1; then dnf install -y -q weston 2>/dev/null; XVFB_ARGS="-c weston"; fi - - UVR_R_BIN=/opt/R/$R_VERSION/bin/R local/uvr-install.sh httr2 + - XVFB=$(command -v xwfb-run 2>/dev/null || command -v xvfb-run); XVFB_ARGS=""; if command -v xwfb-run >/dev/null 2>&1; then dnf install -y -q weston 2>/dev/null; XVFB_ARGS="-c weston"; fi + - /opt/R/$R_VERSION/bin/R -q -e 'pak::pak("httr2")' - /opt/R/$R_VERSION/bin/R -q -e 'source("local/fetch-rebuild-packages-from-issue.R")' - - $XVFB $XVFB_ARGS -n $SPLIT_INDEX -- /opt/R/$R_VERSION/bin/Rscript local/rebuild-missing.R $SPLIT_INTO $SPLIT_INDEX $REBUILD_BUDGET_HOURS 2>&1 + - $XVFB $XVFB_ARGS -- /opt/R/$R_VERSION/bin/R -q -e "sink(stdout(), type = 'message'); options(crayon.enabled = TRUE, Ncpus = $NCPUS, future.globals.onReference = NULL); pkgs <- readLines('/tmp/rebuild_pkgs.txt'); if (length(pkgs) == 0) { cat('Nothing to rebuild\n'); q('no') }; excluded <- jsonlite::fromJSON('local/excluded-packages.json')[['package']]; pkgs <- setdiff(pkgs, excluded); cat(sprintf('Rebuilding %d packages\n', length(pkgs))); n <- length(pkgs); for (i in seq_along(pkgs)) { x <- pkgs[i]; cat(sprintf('[%d/%d] %s\n', i, n, x)); tryCatch(bincraft::build_binary_package(x, tag_limit = 1L, patches = 'local/patches', s3_endpoint = 'https://s3.eu-central-003.backblazeb2.com', s3_region = 'eu-central-003', s3_bucket = 'devxy-rpkgs-binaries', s3_access_key_id = Sys.getenv('B2_S3_ACCESS_KEY'), s3_secret_access_key = Sys.getenv('B2_S3_SECRET_KEY'), metadata_db_host = 'r-binaries.devxy.io', metadata_db_name = 'build_metadata', metadata_db_table = 'single_builds', metadata_db_user = 'rpkgs', metadata_db_password = Sys.getenv('PGPASS'), metadata_db_sslmode = 'require', metadata_db_port = 15432, archive = TRUE, upload = TRUE, store_build_metadata = TRUE), error = function(e) cat(sprintf('ERROR building %s - %s\n', x, conditionMessage(e)))) }" 2>&1 backend_options: docker: resources: diff --git a/.crow/weekly-rebuild-reindex.yaml b/.crow/weekly-rebuild-reindex.yaml deleted file mode 100644 index 698f503..0000000 --- a/.crow/weekly-rebuild-reindex.yaml +++ /dev/null @@ -1,201 +0,0 @@ -# Re-index and purge after weekly-rebuild-missing. -# -# weekly-rebuild-missing runs three shards per slot. Each of them replaces -# objects in place, so the slot's index still advertises the old MD5 and, for -# anything that had been served from source, no Built stamp. Re-indexing from -# inside a shard would mean three concurrent `upload_package_index()` calls on -# one prefix: `cranlike::update_PACKAGES()` lists the live bucket, so an early -# lister that uploads last publishes an index missing its siblings' work. -# -# So it happens exactly once per slot, here, after every shard has finished. -# `runs_on: [success, failure]` keeps that true when a shard fails; only an -# explicit cancel skips it, and this pipeline can then be triggered on its own. - -variables: - # Mirrors the gate on weekly-rebuild-missing so a manual run re-indexes - # exactly the slots it rebuilt. A manual pipeline creation instantiates every - # file in .crow/, so the default must match no matrix row. - weekly_rebuild_missing: - description: "Manual run target: a specific -, 'all' to run every os/arch in parallel, or 'none' to run nothing." - options: - - none - - all - - 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: cron - cron: weekly-rebuild-missing-${OS}-${ARCH} - - event: manual - evaluate: 'weekly_rebuild_missing == "all" || weekly_rebuild_missing == "${OS}-${ARCH}"' - -depends_on: - - weekly-rebuild-missing - -runs_on: [success, failure] - -skip_clone: true - -labels: - group: rpkgs-${ARCH} - -matrix: - include: - - 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: 'Re-index the slot' - 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 - RED_HAT_DEV_PW: - from_secret: RED_HAT_DEV_PW - B2_S3_ACCESS_KEY: - from_secret: B2_S3_ACCESS_KEY - B2_S3_SECRET_KEY: - from_secret: B2_S3_SECRET_KEY - # The index build reads the ABI classifier cache to decide which packages - # may be carried from the flat slot into a per-minor one. Without the - # password that query fails, and it fails soft, so the run still succeeds - # while quietly using an empty risky set: exactly the state the cache was - # added to fix. It logs one connection error per slot. - PGPASS: - from_secret: PGPASS - REPO_RO_TOKEN: - from_secret: REPO_RO_TOKEN - # uvr resolves bincraft's dependencies through the GitHub API, including - # the pat-s/cranlike@s3 fork. Unauthenticated it is rate limited, and the - # failure is not loud: uvr warns, falls back to the registry copy of - # cranlike, and the index write then dies on `unused argument (built = - # built)` because upstream has no such parameter. Intermittent by nature, - # since it depends on the rate-limit window. - GITHUB_PAT: - from_secret: GITHUB_PAT - GIT_USER: pat-s - R_LIBS_USER: /mnt/cache/R-pkgs - R_VERSION: ${R_VERSION} - PLATFORM: ${OS} - ARCH: ${ARCH} - commands: - - git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git . - - mkdir -p /mnt/cache/R-pkgs - - rm -rf /mnt/cache/R-pkgs/00LOCK-* - - /opt/R/$R_VERSION/bin/Rscript local/install-bincraft.R - # The codename is detected from the image's /etc/os-release. - - /opt/R/$R_VERSION/bin/R -q -e 'library(bincraft); upload_package_index(s3_endpoint = "https://s3.eu-central-003.backblazeb2.com", s3_region = "eu-central-003", s3_bucket = "devxy-rpkgs-binaries", s3_access_key_id = Sys.getenv("B2_S3_ACCESS_KEY"), s3_secret_access_key = Sys.getenv("B2_S3_SECRET_KEY"))' - - | - for RBIN in /opt/R/[0-9]*/bin/R; do - RMINOR=$(basename "$(dirname "$(dirname "$RBIN")")" | cut -d. -f1-2) - /opt/R/$R_VERSION/bin/R -q -e "library(bincraft); upload_package_index(r_minor = '$RMINOR', s3_endpoint = 'https://s3.eu-central-003.backblazeb2.com', s3_region = 'eu-central-003', s3_bucket = 'devxy-rpkgs-binaries', s3_access_key_id = Sys.getenv('B2_S3_ACCESS_KEY'), s3_secret_access_key = Sys.getenv('B2_S3_SECRET_KEY'))" || true - done - - - name: Purge CDN cache - image: reg.devxy.io/docker.io/library/alpine:3.24 - environment: - OTEL_R_TRACES_EXPORTER: none - OTEL_R_LOGS_EXPORTER: none - OTEL_R_METRICS_EXPORTER: none - BUNNYNET_API_KEY: - from_secret: BUNNYNET_API_KEY - # cran.rpkgs.com and cran.allianceswisspass.devxy.io are on separate - # Bunny pull zones, so both must be purged after the shared origin changes. - # The staging zone is listed too. It shares the B2 origin, so an index - # it still holds is a stale copy of the same object, and its - # cache_expiration_time is the same ~370 days: without a purge here it - # serves pre-reindex indexes indefinitely and any verification run - # against it measures the past. - BUNNY_PULLZONES: '3857050 cran.allianceswisspass.devxy.io cran-rpkgs-test.b-cdn.net' - commands: - - apk add --no-cache -q bash curl jq - # Crow carries the checkout from the re-index step into this step. - - bash scripts/purge_cdn_zone.sh "$BUNNYNET_API_KEY" $BUNNY_PULLZONES - # Runs on every row rather than on one designated slot: a cron fires only - # its own slot's row, so gating on a named slot would leave every other - # slot unpurged. A manual "all" run therefore purges the zone 18 times, - # which is a cheap API call and rare. - # - # Run it even when the re-index above failed: the objects were still - # replaced, and a stale edge is exactly what keeps them hidden. - when: - - status: [success, failure] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e0ba9a9..a0f4422 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,39 +7,30 @@ exclude: | benchmark/| docker/reprex/ ) -# The `^local/patches/.*\.patch$` excludes below keep unified diffs byte-exact: -# a context line for a blank line is a single space, and stripping it (or -# appending a newline) makes `git apply` reject the patch, which surfaces as -# "patch did not apply cleanly" at build time rather than as a lint failure -# here. The exclusions are per-hook, not global, so `validate patch registry` -# still runs when a patch changes. repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: end-of-file-fixer - exclude: ^local/patches/.*\.patch$ - id: trailing-whitespace - exclude: ^local/patches/.*\.patch$ args: - --markdown-linebreak-ext=md - repo: https://github.com/DavidAnson/markdownlint-cli2 - rev: v0.23.3 + rev: v0.23.0 hooks: - id: markdownlint-cli2 - repo: https://github.com/rbubley/mirrors-prettier - rev: v3.9.10 + rev: v3.9.4 hooks: - id: prettier - repo: https://github.com/posit-dev/air-pre-commit - rev: 0.12.0 + rev: 0.10.0 hooks: - id: air-format - repo: https://github.com/editorconfig-checker/editorconfig-checker - rev: v4.0.2 + rev: v3.8.0 hooks: - id: editorconfig-checker - exclude: ^local/patches/.*\.patch$ - repo: https://github.com/adrienverge/yamllint.git rev: v1.38.0 hooks: diff --git a/.terraform.lock.hcl b/.terraform.lock.hcl index 4b118a8..6ef10fa 100644 --- a/.terraform.lock.hcl +++ b/.terraform.lock.hcl @@ -2,79 +2,79 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/hashicorp/http" { - version = "3.6.2" + version = "3.6.0" hashes = [ - "h1:7+Ewr07oLewLfoe5K7Fhy4T5JUU4y7I7iRGTG2hrzy4=", - "h1:8AvJ6H30J1tgxwEoVKxe0B0Ac9XFn1gwOzidrIc+F5o=", - "h1:AllxtaK2m88aY+QitHui6kik/qJmBakrH5o5W7vOKI0=", - "h1:OimQGm1/9sIkdDCuUb8D5sxDorhektHeCQ337DHDfGk=", - "h1:T5zxkFXQ9To+ZysjCZu6gFqE8G1aTGh58Ol20bCk1Fc=", - "h1:TZlCCHsmKXSI3FyzmSgCU+TarKOHgiOft0wiYh85Y+E=", - "h1:VgyzPdGw1U/KU8gKhefKlIhy5kQYdpoZkpNcGiTgvVs=", - "h1:aF+Ztfy9m0Ll6nFOtC90NxN6WIEKAmfmVFhXAtFF8CI=", - "h1:bgViIoXF7kMXA7xgNikFYfv1D15rregO8I8Sq95sLGQ=", - "h1:d7tCAc1wr1seWpGMBx7tpRyGKOCicvozaHvn5X8ZFfM=", - "h1:dRCrpHH2UrkKs/lCqewyA00L0VfOCETVM14NBhyxHaM=", - "h1:eIqaNIixQTlhmr2GU1fAFH+eVYLQIfeGw7Ec+JT/bEo=", - "h1:hrK+Jzpgivi4Jo356K3LYDoeWBV1bMqKPBEqrEHwgpA=", - "h1:mrpIAC+TK3rQQz2jllAF8o2WLMKM14/WoclLgYKc4WA=", - "h1:zwE/uNBapBDpkKa5I24moawVPTanbG+eKumHRm/qINw=", - "zh:0cf6173a426f65a85e8baf4f9a7808dc94d0e02a82b34bc903555fda1eb45d7a", - "zh:2b5fa2b0487e692014fa86745a2a98d43f34f7aaea044acd3e2cee139627c9f1", - "zh:2c9c18893ce0f34e077559632c88237d172ac6646853bf00c84a5a4ec3b6c241", - "zh:316c848e892764aa10a890cf2455f52b592c6051a92ac8451955ca1d47ca1d2f", - "zh:4300e32ecaa967ca0ed56ca48f8dcd10796e70203bf6b8311e71dc43cd0d2aa1", - "zh:4a0a555d85a31bd257399253b502ad03816ccdba7b8fbbbd1e07a313d04d83fb", - "zh:6278773c31c1605c6d780fc982eacb83944e2807c8d8afe11534253b225c5aa3", - "zh:81b73da8dbcedf54604f131e0acd7ebd36cf673276174b4b4f10af0b0f10de11", - "zh:887beaa6ef6521d3f5e2b1193d458575fb91855f36bc66dd26a10988127a39fb", - "zh:91b2260424c5f8e5968d9e6d5bdbbdb60d61ccc4ff001aa0d637427629349c54", - "zh:a5d9d7032d00a6dd844e8d0d374b5ae89e25f9a6635a90e01c5f65e9dd14a93e", - "zh:bcf8b2aca448774e4701f221b74d762847be55dc312022b4aad66da3b389bf5d", - "zh:d62a3ad37ee1fb943ad0590978f4e7ee0ac064d19554c1c05ec98e9d2bb658f7", - "zh:e517b8b81fe88bf49868923f076515b9157771d1d3a80849cc8a73b9a19039a1", - "zh:fa9e0e39ef3e5ed2e6cf5886a0b569415ca3b89b9289fd6d01824ff11e470618", + "h1:0n4RBz9zNw6TTddh5+x7E8L2+qzPXNwKhK4uoZ/DUwE=", + "h1:22Ob7lpzMBSqdrCvoFN5EgmhGPHPBovV/9qo0c/Cd+A=", + "h1:2IRBvmWOYrq/ooaYYn2i86jZb7iIUvlg0KlmOMfDHoQ=", + "h1:5mucXikk4OcW3un3u94QnMx4AB4Wfih+sXeMd5QxSNk=", + "h1:5oU7Zm+2gAVGmxqtJ9E8uTudUkYy/DEn/y3IWphdv4k=", + "h1:5w0R4b1/VSzpqQF1tXXPr/qmaQLPVRXamOmPKWFcTk4=", + "h1:AEVeJr8xGmwad+JUUQ833C3x5d4W+W2szF5DfwxYppw=", + "h1:CPHJ+0zQbS/cX1m55Y90jIOgf1jV3ocUUnqsXAh+9Eg=", + "h1:JPewnGDOJudNer5+ghqwXoaJkfot3QRq9uiEYvo+JHU=", + "h1:QzbluV2vQLxsJYxjpziQCmPndIoJ/UGS4/UHH/GpwUM=", + "h1:TjUNbUdqweRBq/ycQ4ixpNkx5qaYwpXEOn9QCpqNZP8=", + "h1:XNbcODP60ajj21N/OO7af8bBg1ltIsYkq9egn7BYbiY=", + "h1:tgrbgmX7WYQz9G9ncgu7TkpVB+RlLjJA/Rvp9KPlZH8=", + "h1:vLxthX/ZWsOZ+aHKbAMqmNKqD0K5f4nJ8ppy0Ioyup0=", + "h1:wZOdGBAZkY8OKEPjKz82j1HloAKOmmvtjWyTxM+I110=", + "zh:0f719fa5426bc883e9fa6abf7f6498e48025edafbc29015e2f5c028f1cca3b9d", + "zh:1b4d7dafefd6c61764b2f9ed6943ceb9a200dee3590d18747e3a5f6b20ce85e0", + "zh:1d23a712984866d29f7b07028a4e99c783c71f1a5dddf08bc3d4e7da9d91a1fa", + "zh:257d23d58c3bb024b6bc8eb88736eaf912e934ad47c639d0c3c742bddda849a1", + "zh:479860e1a5468f5e04013b9364c9496d7ed0804bf9a1acd8e07558d57609993d", + "zh:4cb5e681bf599b411b27c4a2c4066a5fb2ed79aaa3a1a3cb5a30002fec062ce9", + "zh:4fb35c3f643dae9f3670d719397a415f815a0b95f8ed7bd8a72f27a94ba78092", + "zh:59ba40825ab38db5b4a0989a2db0df35cc15d8984f898176011ba352f27d77b7", + "zh:61fc1252eb88088638f4c69ea4e2171cde2e5089fa632ac1e943b13787348f73", + "zh:7c5d6dd5f7cbc460e95d368be35c29b4e0402069b8912dbd5d1cd7fa9acef216", + "zh:7f76d756240d4284642f359ad470226e5378670239aadc366ef54d9d914d4d2e", + "zh:8133ad0814098177e0d067c816ccf1bf48bbadacd18f6f2c808c90447505723b", + "zh:c93be06269bb728f1968f8c50506de56c887017ac1d6e4be1f925651d8437eb6", + "zh:ef47b78a10a82e6cf53344a6a85a94041c28286c10a70541c564d762f1cfede0", + "zh:f5796a53a74999135bd9087aff50fddda59129d09b2f9b1902ff8c0c1e047e48", ] } provider "registry.terraform.io/bunnyway/bunnynet" { - version = "0.19.1" - constraints = "~> 0.19" + version = "0.15.1" + constraints = "~> 0.15" hashes = [ - "h1:02CmekB5A4NtAeptTR7waaECJl66Gf3ww7Wtyg1cy+g=", - "h1:0VWwMHkHQXusR2vdyYWN1pNvfJUPt9OWh6glgZshCck=", - "h1:2NGfMzSWPCcyO2P2T9/lDS5MExYnaOfLuDEMiYvFBg4=", - "h1:2dXsvcYEGXsIf1IHD2Rq9HzgwrNNEA4+qOzBKpbFEpw=", - "h1:8R0H9DumQe0DjXTHRJXNiZUyTBDHT2vjfu/vB+WdRIw=", - "h1:9Gi5M2iKY+Oz8wAYoJh7i0u3mySBk5pretuyzcwgJOw=", - "h1:BZwoP8Ubi1iDueWXhSWb3Tlt/II4bJI0/p6/tqw4i1M=", - "h1:F4yWXkyv55F+97IQi/jYBLhBLyhz4hkokVCyEEQzbM4=", - "h1:MGHXEapStuXemfw3SikP2aLQCT+b7Ee/RrCHevSAAWA=", - "h1:Q10nENYoX8jIsBMz1kP8OBrkMeSNmFI2CfB6/MsWKAc=", - "h1:R9z1oacW8gD9hPTGTO37ngDAhAvlP33/PRhzkgXDStY=", - "h1:UYLBa5/V+giaZ2YYjkbPDKMRO9eQRNcWEnpZqU2I/yU=", - "h1:Wz/QDxt1Kved07fCwqMh+6jtTtwQ/xKi9mXE2StV7vw=", - "h1:Yq0JSIW4Jir3EVO2w7C/iM76yzYKG5eYw51woX6CXJo=", - "h1:e/PEmAJszFbjdfsAMJosDbkxtHwHNnByd/3nKJmE3Sg=", - "h1:lMVwei5IGLQCrPMqAmcpbXs/BjaG7k6HbjhuWtr0kIA=", - "h1:m8dyuq1sXJ9sxjZkU3ZUsjC77BWY87GiraNvxHluSYs=", - "zh:15e84e2fca2bbaec9c9f4d14d1a037d02f4d8cc1d1bdff04709d51085f950774", - "zh:1b9d0cbb7f08e7a554afb8c1e733942a77aa0c8112e8b2b7092c040794f0b113", - "zh:28768988a8ec70e5392ca93375717b83a9aa2eeaa21be2f53c28550273b97065", - "zh:2f357c1d2c013627e4e785c350fc72ccc0bfbebaa6062e536ec7eb9cfecfc913", - "zh:4a41bab7fba5415b8c422f5c03d6fb054ae210e1eb180fab4081d4fa976080b5", - "zh:6c10fad0bd03bbf053b97ad51f0a24e9a0c43a7fc10727033a39eec90ed0a51e", - "zh:795980ac64b0f9bda521f829187eb7b296baa682b7e8d409126f85e6a6f9bcc0", - "zh:841a7e234852ecd516e2dc52c7d31025a704242aeafee15eb36bf392e62f918f", + "h1:/2NUpbtjkc+w6n5V3kGP0rSzGjN0K2Wdfe2K+CZdmhU=", + "h1:1TOrpmCR0aT5xX1sjt70zqlYkMJnVe6r0nx2B0DS/mE=", + "h1:4uC8r8ILr+vZJ230uGqIUaWQ7uORCrIzoEuYrAq2JuE=", + "h1:6b+2osJUfwcaYZ6mathLPf/58sr/4XkLXQrcSufnkMk=", + "h1:CPldfjf79QS8mIP+GWoS0FVhrFlyjvN2+39zfyP76Ik=", + "h1:EvXICHyGIKpoYlDeKHOPzfmpvdRdhgsOMcR2nb6+tKY=", + "h1:GZLq+nDxS1CyBH0ELGTSQj9X7ozemJ1jpPA4KwbtR+c=", + "h1:ISNFqL745IQgZ6yMLy8ofV8ixbYqZYa9JKdi2W3pmNk=", + "h1:IrNrEuvFd0nYDGQefwmT8d1CSJb9e8LN5w9vw1ODp7E=", + "h1:Ms79slY9bZ94+n4cwIHvI9+/cvbucwo6S1+z5KAiznw=", + "h1:NWA9XSEBcpSkgwwIvl6tHrxGQY3uYhqNS4Vnb9RLyLQ=", + "h1:UjvxxxggicLtiE3yTe1Gx0oLUTeZpWmgIfXuHzWHn1c=", + "h1:VgJjo14DGkU4Jwo4D3GT4/5sq1tdjiZscKS5l3cb890=", + "h1:dBu3AW5YNLIvbBIMNk3wUHKw4TW+BDbj34a+mCqYhWE=", + "h1:i5oGD06nQ3JRsBIa2u4wCej+ETgp970CFl75dOKkHno=", + "h1:mTqR+vD1AWPx+mu7S0/pzBy71z7WOrjH7arOP77PXh4=", + "h1:nmTM61G8vYjpofeEqspMORpsNvTGCNZySGfjdXardL0=", + "zh:0f9bf5aaa47164a4d6ae4433d5e285a9456a5053401b2bad4ed68622f574ddee", + "zh:3039bee421fb8855a919f449fc731d145254371f5f0c39cc4660f3aeed6a8b10", + "zh:36664b08186e0c194747b18dee24ed97327c6e704133d4cf0df27abef1652f86", + "zh:3c7eae99d8c5ff65dfb99c8b9c1980147282d68971e1ef1ff0f126a6bff59d8f", + "zh:5293cc21abf54f4e5745437ca2d40d206aae323b2e1d41cf45dcc63a8868cbb3", + "zh:5994e5145e616e7e881010717e4c7def2945eb6d933f62db4ec3167732ccac84", + "zh:7994db9ed3fdb6cbf21f2154ea962cf82f04e425988bedc6657de2497d6cb6c3", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:8c876a0a26b249151f0918ee836e24660513c2345c23a06e0f54a28c7c6966b7", - "zh:906a791496cf11dcfdfe43a4167414cb473e548f0bd86d4b8a6a95450b523133", - "zh:9431757bf5b7d055ecc1d3c8465216d5e7a648b75e0e35b136ac8fc25382e97d", - "zh:967fd248df86fc246536727e996a7334711f4c6fe9e3d01ec9f03abe621a9783", - "zh:a6710fbd30f8a4844eb025d616057e78bbb255a7d05cc73876dae55a5d6b0d06", - "zh:b5f260899555430c109da35d2c483999d61b127beda6c2a7b39eb67f27d754c8", - "zh:d24882eb63c6452ed27666e896224b1d866ad00645d6ecc53de84d1687d27fc4", - "zh:e573eb0c99ea7195ec1ed3da728caddacc68a126d82679fb490438c781382f4d", - "zh:fcf0a66c0c0b62af505c5f0f8fe59bca77d6085a0b07763be33420101183702e", + "zh:93aa863e536ba9376ccf9e614e9edc9b214a2ce8c4316d416a8e249b436f52d2", + "zh:ab5cb4baeda57559686a0ccf0e09158aa64624ee6ba0ef32b769f13b11a43068", + "zh:ae9388b62eede8fd9272407bf75f8241a965bd489d45ec9dd3f9fac696d500e1", + "zh:caa5befd16960e2f69c7ec483e228e5ff43ab0979c17f1b874c9ffaa1c7c0e43", + "zh:cddd3e1067defa06a4e4ad5cb3940c7943e29c42417de57236aa7d3e2aeaae13", + "zh:cdfa44d591d0805116159556947904d70f534c6816188c45cf3a7544d2722ac9", + "zh:d607e9f1f3e09f13404f219e1893e3b3c77aece4afb54e999f934c021f41f576", + "zh:d8a397aca95125c6a0c0c78d2ded5843b9204effa9f5cf7419f017b500ad9228", + "zh:f5499eaff0d221725ad209d27d87c5b46d5c554caad7dc42947c760377abe3b0", + "zh:fc5f5cf433abc83e5169fa222992ec521c0c802075970251e3dd2c1d50c4f5c1", ] } diff --git a/README.md b/README.md index 99d1143..28541a1 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ For every package+tag combination: ## Patching packages Some CRAN packages fail to compile on specific platforms due to compiler- or OS-specific issues unrelated to the package itself. -The canonical example is `RcppParallel`, whose bundled TBB is linked in a way that lets a system TBB on the build host shadow it, so the published binary depends on a library the consumer does not have. +The canonical example is `RcppParallel`, whose bundled TBB sources fail on musl (Alpine) and newer compiler/OS combinations. Because such packages are often transitive dependencies of many others, a single failure cascades: all dependents fail even though nothing is wrong with the dependent itself. To address this, frequently-failing packages can be "patched" before they are installed — whether as a direct build target or a transitive dependency pulled in by `pak`. @@ -84,23 +84,6 @@ This elastic server architecture offers a robust and performant backend while mi ## Technical Details -### Packages requiring a newer R - -The daily update and weekly full-build pipelines run `local/build-newer-r.R` under each non-primary installed R, alongside the ABI-sensitive pass. -It selects current CRAN versions whose `Depends: R` constraints cannot be met by the slot's primary R but can be met by the running R, including incompatibility inherited through `Depends`, `Imports`, and `LinkingTo`. -These builds retain bincraft's ABI classification and storage routing: sensitive binaries use the running minor, while safe binaries can be shared. -No shared S3 existence cache is passed, so bincraft checks the actual objects and distinguishes a source fallback from a binary. -The pass reads unfiltered CRAN metadata independently of the weekly snapshot and its primary-R failure exclusions; existing binaries and failures for the running minor retain bincraft's usual skip behavior. -Weekly workers split this extra work across their existing shards. -This does not select older archive versions for unsupported R minors or change index filtering. - -Preview the selection without building or accessing storage or build metadata: - -```sh -# Run with the target R and supply the slot's full primary R version. -/opt/R/4.6.0/bin/Rscript local/build-newer-r.R 4.4.3 --dry-run -``` - ### Creating/Updating the PACKAGES Index Files Currently, the {cranlike} and {desc} packages only work with files on a local file system. @@ -129,9 +112,9 @@ Processing updates with an existing database file takes around 5 minutes. ### Inferring System Dependencies -R package dependencies and their system dependencies are installed through [uvr](https://github.com/nbafrank/uvr). -`uvr` allows for parallel downloads and installation, significantly speeding up package installation compared to `install.packages()`. -Additionally, it automatically infers package dependencies using JSON rules vendored from [rstudio/r-system-requirements](https://github.com/rstudio/r-system-requirements). +R package dependencies and their system dependencies are installed through {pak}. +{pak} allows for parallel downloads and installation, significantly speeding up package installation compared to `install.packages()`. +Additionally, it automatically infers package dependencies using JSON rules from [rstudio/r-system-requirements](https://github.com/rstudio/r-system-requirements). Not all R packages specify required system dependencies in their DESCRIPTION file, and not all listed dependencies have existing rules in `rstudio/r-system-requirements`. For Alpine, no rules existed until recently, establishing a foundation for semi-automated package installation on Alpine Linux. @@ -169,13 +152,12 @@ A Shiny dashboard providing a search functionality of the database and grouped s Is supported by writing `Meta/archive.rds` during each package index update, listing all available archived packages. -### `uvr add package@version` +### `pak::pak(package@version)` -Clients that resolve archived versions search for `Archive/` and can install all versions they find. -This holds for `uvr` as well as for older `pak`-based clients. +`pak` searches for `Archive/` and can install all versions it finds. Ensure to use a clean cache if other repositories have been used previously. -If in doubt or when testing, call `uvr cache clean`. +If in doubt or when testing, call `pak::meta_clean(force = TRUE)`. ## Lessons Learned @@ -198,7 +180,7 @@ If in doubt or when testing, call `uvr cache clean`. ## URL Composition and Platform Identifiers -Platform identifiers have been aligned with those used in , which `uvr` vendors and uses for its automatic syslib dependency installer (`uvr sync --install-system-deps`): +Platform identifiers have been aligned with those used in to ensure proper recognition by the automatic syslib dependency installer of `pak`, specifically via the environment variable `PKG_SYSREQS_PLATFORM`: - redhat-9 - redhat-8 @@ -215,28 +197,6 @@ Example: Technically, no date-based snapshots are planned, so this component from the Posit PM structure is not included. -## Retiring a slot - -A slot is only reachable if it is listed in `local.rpkgs_slots` in `cdn.tf`, which feeds `UNION_SLOTS` in `edge/rpkgs-router.ts`. -An unrouted slot serves the flat index to every client, so its binaries fail at `dyn.load()` under every R minor but one. - -In order: - -1. Remove it from `local.rpkgs_slots` in `cdn.tf` and from `DistroId` in the website's `src/src/data/repos.ts`, then `tofu apply`. -2. `just retire-slot --apply`, then commit. -3. Delete its cron jobs on the Crow server; `local/retire-slot.R` prints them with the API call. -4. Delete its objects: - - ```sh - crow pipeline create devxy/build-cran-binaries --branch main \ - --var retire_slot= --var RETIRE_APPLY=yes - ``` - -Steps 2 and 4 refuse any slot still listed in `cdn.tf`. -Running step 4 before step 2 means the next nightly rebuilds everything just deleted. - -`just check-slots` fails when a pipeline still targets an unrouted slot. - ## Helpers Helper scripts are located in `local/`. @@ -277,8 +237,8 @@ internal error 1 in memDecompress Solution: ```sh -uvr cache clean -rm -rf /mnt/cache/uvr /root/.cache/R/ +rm -rf /mnt/cache/R-pkgs/pak /mnt/cache/pkgcache/ /root/.cache/R/ +R -q -e 'install.packages("pak", repos = sprintf("https://r-lib.github.io/p/pak/stable/%s/%s/%s", .Platform$pkgType, R.Version()$os, R.Version()$arch))' ``` diff --git a/build-all-versions-install-deps.yaml b/build-all-versions-install-deps.yaml index dd02e8a..926d550 100644 --- a/build-all-versions-install-deps.yaml +++ b/build-all-versions-install-deps.yaml @@ -30,9 +30,8 @@ # from_secret: HETZNER_S3_SECRET_KEY_K3S # # normal env vars # GIT_USER: pat-s -# # set the location of uvr's caches, which persist the R package dependencies needed to install the packages themselves -# # UVR_CACHE_DIR: /mnt/cache/uvr/cache -# # UVR_PACKAGES_DIR: /mnt/cache/uvr/packages +# # set the location of the 'pkgcache' cache dir which persists the R package dependencies needed to install the packages themselves +# # R_PKG_CACHE_DIR: /mnt/cache/pkgcache # R_LIBS_USER: /mnt/cache/R-pkgs # CCACHE_DIR: /mnt/cache/ccache # volumes: @@ -40,8 +39,9 @@ # commands: # - git clone -q https://pat-s:$$REPO_RO_TOKEN@git.devxy.io/devxy/build-cran-binaries.git . # - mkdir -p /mnt/cache/R-pkgs -# - rm -rf /mnt/cache/R-pkgs/00LOCK-* /mnt/cache/R-pkgs/bincraft /mnt/cache/uvr -# - UVR_R_BIN=/opt/R/$R_VERSION/bin/R local/uvr-install.sh forgejo::codefloe.com/rpkgs/bincraft +# - rm -rf /mnt/cache/R-pkgs/00LOCK-* /mnt/cache/R-pkgs/bincraft /mnt/cache/pkgcache +# - /opt/R/$R_VERSION/bin/R -q -e 'install.packages("pak", repos = sprintf("https://r-lib.github.io/p/pak/stable/%s/%s/%s", .Platform$pkgType, R.Version()$os, R.Version()$arch))' +# - /opt/R/$R_VERSION/bin/R -q -e 'pak::pak("git::https://codefloe.com/rpkgs/bincraft.git", dependencies = TRUE)' # - /opt/R/$R_VERSION/bin/R -q -e 'packageVersion("bincraft")' # # - /opt/R/$R_VERSION/bin/R -q -e "future::plan('multisession', workers = 6L); pkgs = bincraft::query_packages_without_historic_versions('alpine322', 'amd64'); saveRDS(pkgs, '/mnt/cache/pkgs_amd64.rds')" # - /opt/R/$R_VERSION/bin/R -q -e "future::plan('multisession', workers = 6L); pkgs = bincraft::query_packages_without_historic_versions('alpine323', 'arm64'); saveRDS(pkgs, '/mnt/cache/pkgs_arm64.rds')" diff --git a/cdn.tf b/cdn.tf index df30273..8a950f5 100644 --- a/cdn.tf +++ b/cdn.tf @@ -32,7 +32,7 @@ # cache_stale = ["offline", "updating"] # use_background_update = true -# block_ips = var.cdn_block_ips + # block_ips = var.cdn_block_ips # # 50 TB # limit_bandwidth = 50000000000000 @@ -52,66 +52,6 @@ ### cran.rpkgs.com -locals { - rpkgs_slots = [ - for pair in setproduct( - ["amd64", "arm64"], - ["resolute", "noble", "jammy", "rhel8", "rhel9", "rhel10", "alpine323", "alpine324"] - ) : "${pair[0]}/${pair[1]}" - ] - - # The supported R minors: the current one plus the two previous, which is - # exactly what build-env-images installs as R_VERSION_LATEST / PREV1 / PREV2. - # These must stay in step. A minor listed here without a published index - # sends those clients to a 404; a published minor missing from this list - # sends them to CRAN for sources instead of serving the binaries we built. - rpkgs_supported_minors = ["4.4", "4.5", "4.6"] - - # bunny.net serves every pull zone on .b-cdn.net, so staging needs no - # DNS record and is never advertised. - rpkgs_test_hostname = "cran-rpkgs-test.b-cdn.net" -} - -# The edge middleware that resolves the bare cran.rpkgs.com form to an -# / slot and routes PACKAGES* to the per-R-minor slot. The source of -# truth is edge/rpkgs-router.ts; `tofu apply` publishes a new release. -# -# The script pre-dates this configuration, so it is adopted rather than created: -# tofu import bunnynet_compute_script.rpkgs_router 29277 -resource "bunnynet_compute_script" "rpkgs_router" { - type = "middleware" - name = "rpkgs-router" - content = file("${path.module}/edge/rpkgs-router.ts") -} - -# Slots ("/", comma separated) whose per-minor index bincraft has -# already republished as a union of the per-minor and flat slots. Routing to a -# slot that is not listed here would hide every package the per-minor index does -# not carry, so this stays empty until a slot has been backfilled. -resource "bunnynet_compute_script_variable" "rpkgs_router_union_slots" { - script = bunnynet_compute_script.rpkgs_router.id - name = "UNION_SLOTS" - # Enabled. Until this was set, every client resolved against the generic - # index and never reached a per-minor binary: an R 4.6.1 client on resolute - # downloaded the 4.5-built rlang (2079570 bytes) while the correct 4.6 build - # (2075106 bytes) sat unused one directory away, and died at load with - # `undefined symbol: SETLENGTH`. - # - # Verified before enabling, against the staging zone with the same script and - # the same origin: all 16 slots report zero regressions against the generic - # slot, an excluded R minor is sent to CRAN, a client without an R minor - # still gets the generic index, and tarball requests are never rewritten. - default_value = join(",", local.rpkgs_slots) - required = false -} - -resource "bunnynet_compute_script_variable" "rpkgs_router_known_minors" { - script = bunnynet_compute_script.rpkgs_router.id - name = "KNOWN_MINORS" - default_value = join(",", local.rpkgs_supported_minors) - required = false -} - resource "bunnynet_pullzone" "cran_rpkgs_com" { name = "cran-rpkgs" @@ -119,12 +59,12 @@ resource "bunnynet_pullzone" "cran_rpkgs_com" { cache_expiration_time = 31919000 websockets_enabled = false - errorpage_whitelabel = true + errorpage_whitelabel = true origin { type = "OriginUrl" url = "https://devxy-rpkgs-binaries.s3.eu-central-003.backblazeb2.com" - middleware_script = bunnynet_compute_script.rpkgs_router.id + middleware_script = 29277 } routing { @@ -142,21 +82,6 @@ resource "bunnynet_pullzone" "cran_rpkgs_com" { request_coalescing_enabled = true block_post_requests = true - # Set on the zone since before this configuration existed; declared here so - # `tofu apply` stops silently removing it. - # - # The router makes it redundant on paper: the only UA-dependent responses it - # produces are redirects, and those carry `Cache-Control: no-store`, while - # their targets are concrete per-slot, per-minor URLs whose content depends - # only on the path. Dropping it would also be a real win, because otherwise - # every distinct R version string keys its own copy of every tarball. - # - # It stays for now anyway: it is the second line of defence against the one - # failure that would be quiet and confusing (an R 4.6 client served the 4.5 - # index), and removing it is worth doing on its own once per-minor routing is - # confirmed live, not as a side effect of enabling that routing. - cache_vary_headers = ["User-Agent"] - limit_requests = 5000 limit_connections = 1000 @@ -184,151 +109,6 @@ resource "bunnynet_pullzone_hostname" "cran_rpkgs_com" { tls_enabled = true } -### Staging zone for edge-router changes - -# Every published / slot. The staging zone enables per-minor routing -# for all of them at once; production adopts the same list only after -# `scripts/verify-r-minor-routing.sh --live` passes against staging. - -# A second copy of the same router, bound to the same B2 origin, so UNION_SLOTS -# can be exercised end to end before production is touched. -resource "bunnynet_compute_script" "rpkgs_router_test" { - type = "middleware" - name = "rpkgs-router-test" - content = file("${path.module}/edge/rpkgs-router.ts") -} - -resource "bunnynet_compute_script_variable" "rpkgs_router_test_union_slots" { - script = bunnynet_compute_script.rpkgs_router_test.id - name = "UNION_SLOTS" - default_value = join(",", local.rpkgs_slots) - required = false -} - -# Without this the staging zone rewrites to PUBLIC_CDN_ORIGIN, so its redirects -# land on production and the test silently measures the wrong system. -resource "bunnynet_compute_script_variable" "rpkgs_router_test_extra_hosts" { - script = bunnynet_compute_script.rpkgs_router_test.id - name = "EXTRA_PUBLIC_HOSTS" - default_value = local.rpkgs_test_hostname - required = false -} - -resource "bunnynet_compute_script_variable" "rpkgs_router_test_known_minors" { - script = bunnynet_compute_script.rpkgs_router_test.id - name = "KNOWN_MINORS" - default_value = join(",", local.rpkgs_supported_minors) - required = false -} - -resource "bunnynet_pullzone" "cran_rpkgs_test" { - name = "cran-rpkgs-test" - - cache_errors = false - - cache_expiration_time = 31919000 - websockets_enabled = false - errorpage_whitelabel = true - - origin { - type = "OriginUrl" - url = "https://devxy-rpkgs-binaries.s3.eu-central-003.backblazeb2.com" - middleware_script = bunnynet_compute_script.rpkgs_router_test.id - } - - routing { - filters = [ - "scripting", - ] - } - - s3_auth_enabled = true - s3_auth_key = var.B2_S3_ACCESS_KEY - s3_auth_secret = var.B2_S3_SECRET_KEY - s3_auth_region = "eu-central-003" - - cache_enabled = true - request_coalescing_enabled = true - block_post_requests = true - - cache_vary_headers = ["User-Agent"] - - # Staging carries only synthetic verification traffic, so the production - # ceilings would be pure headroom. - limit_requests = 500 - limit_connections = 100 - - safehop_enabled = true - add_canonical_header = true - cache_stale = ["offline", "updating"] - block_ips = var.cdn_block_ips - - # 1 TB - limit_bandwidth = 1000000000000 - - block_root_path = true -} - - -# Alliance SwissPass historically used a separate, manually configured pull -# zone. Adopt it so both public repositories use the same B2 origin, middleware -# release and cache behavior. -import { - to = bunnynet_pullzone.cran_allianceswisspass - id = "3265648" -} - -resource "bunnynet_pullzone" "cran_allianceswisspass" { - name = "cran-allianceswisspass" - - cache_errors = false - cache_expiration_time = 31919000 - websockets_enabled = false - errorpage_whitelabel = true - - origin { - type = "OriginUrl" - url = "https://devxy-rpkgs-binaries.s3.eu-central-003.backblazeb2.com" - middleware_script = bunnynet_compute_script.rpkgs_router.id - } - - routing { - filters = [ - "scripting", - ] - } - - s3_auth_enabled = true - s3_auth_key = var.B2_S3_ACCESS_KEY - s3_auth_secret = var.B2_S3_SECRET_KEY - s3_auth_region = "eu-central-003" - - cache_enabled = true - request_coalescing_enabled = true - block_post_requests = true - cache_vary_headers = ["User-Agent"] - - limit_requests = 5000 - limit_connections = 1000 - - safehop_enabled = true - add_canonical_header = true - cache_stale = ["offline", "updating"] - block_ips = var.cdn_block_ips - - # 50 TB - limit_bandwidth = 50000000000000 - - block_root_path = true -} - -resource "bunnynet_pullzone_hostname" "cran_allianceswisspass" { - pullzone = bunnynet_pullzone.cran_allianceswisspass.id - name = "cran.allianceswisspass.devxy.io" - force_ssl = true - tls_enabled = true -} - # resource "bunnynet_storage_zone" "devxy-r-binaries" { # name = "devxy-r-binaries-storage" # region = "DE" diff --git a/docker/Containerfile-shiny-app b/docker/Containerfile-shiny-app index 4b0bd6c..7c7994f 100644 --- a/docker/Containerfile-shiny-app +++ b/docker/Containerfile-shiny-app @@ -1,15 +1,7 @@ FROM devxygmbh/r-alpine:4.4-3.20 AS build # ARG GITHUB_PAT -RUN apk add --no-cache curl ca-certificates \ - && curl -fsSL https://raw.githubusercontent.com/nbafrank/uvr/main/install.sh \ - | UVR_INSTALL_DIR=/usr/local/bin sh - -# One uvr project drives both dependency installs below. UVR_LIBRARY points the -# syncs at the image's R library instead of the project-local .uvr/library/, so -# `Rscript app.R` finds the packages without a uvr-aware .Rprofile. -ENV UVR_LIBRARY=/usr/lib/R/library -RUN mkdir -p /uvr && cd /uvr && uvr init --here +RUN R -q -e 'install.packages("pak", repos = sprintf("https://r-lib.github.io/p/pak/stable/%s/%s/%s", .Platform$pkgType, R.Version()$os, R.Version()$arch))' COPY --link ./DESCRIPTION . COPY --link ./R ./R @@ -19,19 +11,13 @@ COPY --link ./shiny/app.R /app/app.R RUN ls -la -# install R package deps. uvr has no equivalent of bare `pak::pak()`, which -# reads the DESCRIPTION in the working directory, so extract the dependency -# names and hand them to `uvr add`. -RUN R -q --no-echo -e "d <- read.dcf('DESCRIPTION'); f <- intersect(colnames(d), c('Depends', 'Imports', 'LinkingTo')); p <- trimws(sub('[(].*', '', unlist(strsplit(paste(d[, f], collapse = ','), ',')))); writeLines(setdiff(p[nzchar(p)], c('R', rownames(installed.packages()))), '/tmp/deps.txt')" \ - && cd /uvr && xargs -r uvr add --no-install < /tmp/deps.txt \ - && uvr sync --install-system-deps +# install R package deps +RUN R -q -e 'pak::pak()' RUN R CMD INSTALL --no-docs --without-keep.source . # install shiny app deps -RUN R -q -e "install.packages('renv'); pkgs <- renv::dependencies('/app/app.R')[['Package']]; pkgs = setdiff(pkgs, 'bincraft'); writeLines(pkgs, '/tmp/app-deps.txt')" \ - && cd /uvr && xargs -r uvr add --no-install < /tmp/app-deps.txt \ - && uvr sync --install-system-deps +RUN R -q -e "install.packages('renv'); pkgs <- renv::dependencies('/app/app.R')[['Package']]; pkgs = setdiff(pkgs, 'bincraft'); pak::pak(pkgs)" ENV PGPASS="" diff --git a/docker/build-one.Dockerfile b/docker/build-one.Dockerfile index 8e61ebd..5bc107a 100644 --- a/docker/build-one.Dockerfile +++ b/docker/build-one.Dockerfile @@ -17,10 +17,7 @@ ARG CACHEBUST WORKDIR /work COPY build-one.R /work/build-one.R # Resolve and install the latest bincraft release dynamically (no hardcoded pin). -# uvr-install.sh lands under /work/local/ because install-bincraft.R looks for it -# there when the working directory is not a repo checkout. COPY install-bincraft.R /work/install-bincraft.R -COPY uvr-install.sh /work/local/uvr-install.sh # Ship the patch registry so build-one.R's `patches = "local/patches"` resolves # (build context is `local/`, CWD is /work). COPY patches /work/local/patches diff --git a/docker/reprex/alpine.sh b/docker/reprex/alpine.sh index 851a9cc..94806e5 100644 --- a/docker/reprex/alpine.sh +++ b/docker/reprex/alpine.sh @@ -1,14 +1,10 @@ docker run --rm -it --platform linux/arm64 alpine sh -apk add --no-cache R R-dev g++ curl ca-certificates +apk add --no-cache R R-dev g++ -curl -fsSL https://raw.githubusercontent.com/nbafrank/uvr/main/install.sh | UVR_INSTALL_DIR=/usr/local/bin sh +R -q -e 'install.packages("pak", repos = sprintf("https://r-lib.github.io/p/pak/devel/%s/%s/%s", .Platform$pkgType, R.Version()$os, R.Version()$arch))' -# `uvr add` always writes to .uvr/library/; only `uvr sync` honours UVR_LIBRARY, -# so add without installing and let the sync place the packages. -export UVR_LIBRARY=/usr/lib/R/library -mkdir -p /uvr && cd /uvr && uvr init --here -uvr add --no-install gert purrr && uvr sync --install-system-deps +R -q -e 'pak::pak(c("gert", "purrr"))' R @@ -26,20 +22,14 @@ unlink(sprintf("%s/%s", tempdir(), "tmp1"), force = TRUE, recursive = TRUE) tag <- all_tags$name package_name <- rep(package_name, length(tag)) -# uvr has no `pak::local_install_deps()`; read the DESCRIPTION of the checkout -# and `uvr add` the dependency names instead. purrr::walk2(package_name[1], tag, \(x, y) { print(y) system("git config --global advice.detachedHead false") - src <- sprintf("/tmp/%s_%s", x[1], y) system2("git", args = c( "clone", "-q", sprintf("--branch=%s", tail(y, 1)), - sprintf("https://github.com/cran/%s", x[1]), src + sprintf("https://github.com/cran/%s", x[1]), sprintf("/tmp/%s_%s", x[1], y) )) - d <- read.dcf(file.path(src, "DESCRIPTION")) - f <- intersect(colnames(d), c("Depends", "Imports", "LinkingTo")) - deps <- trimws(sub("[(].*", "", unlist(strsplit(paste(d[, f], collapse = ","), ",")))) - deps <- setdiff(deps[nzchar(deps)], "R") - system2("uvr", c("add", "--no-install", deps)) - system2("uvr", c("sync", "--install-system-deps")) + pak::local_install_deps(sprintf("/tmp/%s_%s", x[1], y)) }) + + diff --git a/edge/rpkgs-router.test.ts b/edge/rpkgs-router.test.ts deleted file mode 100644 index 3e2fe8b..0000000 --- a/edge/rpkgs-router.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * Routing matrix for `edge/rpkgs-router.ts`. - * - * The script is exercised through the SDK's local server rather than by - * importing its internals, so what is tested is the artifact that gets - * deployed. Requests that the script passes through are proxied to the real - * origin, which keeps the "no redirect" cases honest: they assert that the - * client reached the flat slot, not merely that no `Location` was set. - * - * Run with `just edge-test`. - */ -import { assertEquals } from 'jsr:@std/assert@1'; - -const SCRIPT = new URL('./rpkgs-router.ts', import.meta.url).pathname; -const BASE = 'http://127.0.0.1:8080'; -const UNION_SLOTS = 'amd64/alpine324'; - -const UA_R45_MUSL = 'R (4.5.3 x86_64-pc-linux-musl x86_64 linux-musl)'; -const UA_R46_MUSL = 'R (4.6.0 x86_64-pc-linux-musl x86_64 linux-musl)'; -const UA_R43_MUSL = 'R (4.3.3 x86_64-pc-linux-musl x86_64 linux-musl)'; -const UA_R47_MUSL = 'R (4.7.0 x86_64-pc-linux-musl x86_64 linux-musl)'; -const UA_R45_ALPINE = 'R/4.5.3 R (4.5.3 x86_64-pc-linux-musl x86_64 linux-musl) Alpine Linux 3.24'; -const UA_R45_RESOLUTE = 'R/4.5.3 (Ubuntu 26.04) (aarch64-unknown-linux-gnu aarch64 linux-gnu)'; -const UA_R45_FUTURE_UBUNTU = - 'R/4.5.3 (Ubuntu 28.04; codename=dynamic-dugong) (aarch64-unknown-linux-gnu aarch64 linux-gnu)'; -const UA_R45_DARWIN = 'R (4.5.1 aarch64-apple-darwin20 aarch64 darwin20)'; -const UA_CURL = 'curl/8.0.1'; - -const SLOT = '/amd64/alpine324/latest/src/contrib'; -const OTHER_SLOT = '/amd64/noble/latest/src/contrib'; - -interface Probe { - status: number; - location: string | null; - cacheControl: string | null; -} - -async function probe(path: string, userAgent: string): Promise { - const res = await fetch(BASE + path, { - headers: { 'User-Agent': userAgent }, - redirect: 'manual', - }); - await res.body?.cancel(); - return { - status: res.status, - location: res.headers.get('location'), - cacheControl: res.headers.get('cache-control'), - }; -} - -/** Kill tolerantly: the child has already exited if the script failed to load. */ -async function stopServer(child: Deno.ChildProcess): Promise { - try { - child.kill(); - } catch { - // already gone - } - await child.status; -} - -async function startServer(): Promise { - const child = new Deno.Command(Deno.execPath(), { - args: ['run', '-A', SCRIPT], - env: { UNION_SLOTS }, - stdout: 'null', - stderr: 'inherit', - }).spawn(); - - for (let attempt = 0; attempt < 150; attempt++) { - try { - const res = await fetch(`${BASE}/`, { - headers: { 'User-Agent': UA_CURL }, - redirect: 'manual', - }); - await res.body?.cancel(); - return child; - } catch { - await new Promise((resolve) => setTimeout(resolve, 200)); - } - } - - await stopServer(child); - throw new Error('edge script did not start listening on ' + BASE); -} - -Deno.test('rpkgs-router', async (t) => { - const server = await startServer(); - - try { - await t.step("routes an index request to the client's R minor", async () => { - const res = await probe(`${SLOT}/PACKAGES.gz`, UA_R45_MUSL); - assertEquals(res.status, 302); - assertEquals(res.location, `https://cran.rpkgs.com${SLOT}/4.5/PACKAGES.gz`); - }); - - await t.step('routes R 4.6 to its own slot', async () => { - const res = await probe(`${SLOT}/PACKAGES.gz`, UA_R46_MUSL); - assertEquals(res.location, `https://cran.rpkgs.com${SLOT}/4.6/PACKAGES.gz`); - }); - - // We publish binaries only for the supported window. An excluded minor has - // no slot we can serve safely, so it goes to CRAN for sources rather than - // to a 404 or to binaries built under another minor. - await t.step('sends an excluded R minor to CRAN for the index', async () => { - const res = await probe(`${SLOT}/PACKAGES.gz`, UA_R43_MUSL); - assertEquals(res.location, 'https://cran.r-project.org/src/contrib/PACKAGES.gz'); - }); - - await t.step('sends a future R minor to CRAN too', async () => { - const res = await probe(`${SLOT}/PACKAGES.gz`, UA_R47_MUSL); - assertEquals(res.location, 'https://cran.r-project.org/src/contrib/PACKAGES.gz'); - }); - - // The index and the tarballs R resolves against it have to come from the - // same place. Serving one from CRAN and the other from here would hand R a - // binary where it expects a source tarball. - await t.step('sends an excluded minor to CRAN for tarballs as well', async () => { - const res = await probe(`${SLOT}/foo_1.0.tar.gz`, UA_R43_MUSL); - assertEquals(res.location, 'https://cran.r-project.org/src/contrib/foo_1.0.tar.gz'); - }); - - await t.step('leaves an excluded minor alone on a slot outside UNION_SLOTS', async () => { - const res = await probe(`${OTHER_SLOT}/PACKAGES.gz`, UA_R43_MUSL); - assertEquals(res.location, null); - assertEquals(res.status, 200); - }); - - await t.step('routes PACKAGES and PACKAGES.rds too', async () => { - for (const file of ['PACKAGES', 'PACKAGES.rds']) { - const res = await probe(`${SLOT}/${file}`, UA_R45_MUSL); - assertEquals(res.location, `https://cran.rpkgs.com${SLOT}/4.5/${file}`, `expected ${file} to be routed`); - } - }); - - await t.step('marks the redirect uncacheable', async () => { - const res = await probe(`${SLOT}/PACKAGES.gz`, UA_R45_MUSL); - assertEquals(res.cacheControl, 'no-store'); - }); - - await t.step('leaves a slot outside UNION_SLOTS alone', async () => { - const res = await probe(`${OTHER_SLOT}/PACKAGES.gz`, UA_R45_MUSL); - assertEquals(res.location, null); - assertEquals(res.status, 200); - }); - - await t.step('never routes a tarball', async () => { - const res = await probe(`${SLOT}/jsonlite_2.0.0.tar.gz`, UA_R45_MUSL); - assertEquals(res.location, null); - assertEquals(res.status, 200); - }); - - await t.step('serves an archived binary when it exists', async () => { - const path = `${SLOT}/Archive/xml2/xml2_1.5.2.tar.gz`; - const res = await probe(path, UA_R45_MUSL); - assertEquals(res.status, 200); - assertEquals(res.location, null); - }); - - await t.step('does not redirect a path already under a minor', async () => { - const res = await probe(`${SLOT}/4.5/PACKAGES.gz`, UA_R45_MUSL); - assertEquals(res.location, null); - assertEquals(res.status, 200); - }); - - await t.step('leaves a client without an R version alone', async () => { - const res = await probe(`${SLOT}/PACKAGES.gz`, UA_CURL); - assertEquals(res.location, null); - assertEquals(res.status, 200); - }); - - await t.step('resolves the bare root to slot and minor', async () => { - const res = await probe('/src/contrib/PACKAGES.gz', UA_R45_ALPINE); - assertEquals(res.location, `https://cran.rpkgs.com${SLOT}/4.5/PACKAGES.gz`); - }); - - await t.step('resolves Ubuntu 26.04 to the resolute slot', async () => { - const res = await probe('/src/contrib/PACKAGES.gz', UA_R45_RESOLUTE); - assertEquals(res.location, 'https://cran.rpkgs.com/arm64/resolute/latest/src/contrib/PACKAGES.gz'); - }); - - await t.step('resolves a future Ubuntu release from its codename', async () => { - const res = await probe('/src/contrib/PACKAGES.gz', UA_R45_FUTURE_UBUNTU); - assertEquals(res.location, 'https://cran.rpkgs.com/arm64/dynamic-dugong/latest/src/contrib/PACKAGES.gz'); - }); - - await t.step('sends an unidentifiable distro to CRAN', async () => { - const res = await probe('/src/contrib/PACKAGES.gz', UA_R45_MUSL); - assertEquals(res.location, 'https://cran.r-project.org/src/contrib/PACKAGES.gz'); - }); - - await t.step('keeps the macOS rewrite', async () => { - const res = await probe('/src/contrib/foo_1.0.tar.gz', UA_R45_DARWIN); - assertEquals(res.location, 'https://cran.rpkgs.com/bin/macosx/big-sur-arm64/contrib/4.5/foo_1.0.tar.gz'); - }); - - await t.step('keeps the macOS binary passthrough to CRAN', async () => { - const path = '/bin/macosx/big-sur-arm64/contrib/4.5/foo_1.0.tar.gz'; - const res = await probe(path, UA_R45_DARWIN); - assertEquals(res.location, `https://cran.r-project.org${path}`); - }); - - await t.step('collapses duplicate slashes before matching', async () => { - const res = await probe(`/amd64/alpine324//latest/src/contrib//PACKAGES.gz`, UA_R45_MUSL); - assertEquals(res.location, `https://cran.rpkgs.com${SLOT}/4.5/PACKAGES.gz`); - }); - } finally { - await stopServer(server); - } -}); diff --git a/edge/rpkgs-router.ts b/edge/rpkgs-router.ts deleted file mode 100644 index 05b6e18..0000000 --- a/edge/rpkgs-router.ts +++ /dev/null @@ -1,316 +0,0 @@ -/** - * Edge middleware for cran.rpkgs.com. - * - * Two jobs: - * - * 1. Resolve the bare `https://cran.rpkgs.com` form to a concrete - * `/` slot from the User-Agent, or send the client to CRAN when - * the distro cannot be identified. - * 2. Route `PACKAGES*` requests to the per-R-minor slot - * (`…/latest/src/contrib//`), so a stock `install.packages()` sees the - * packages that only exist there. - * - * Only index files are routed. Tarballs are deliberately left alone: R keeps - * the contrib URL it asked for, not the one it was redirected to, so every - * tarball URL is resolved against the flat directory and the union index steers - * the per-minor ones with a `Path: ` field. Rewriting a tarball request - * here would send flat-slot packages into a directory that does not hold them. - * - * Routing is gated on UNION_SLOTS. The raw per-minor index holds only the - * ABI-sensitive subset of a slot; it is safe to route to it only once bincraft - * has republished it as a union of the per-minor and flat slots. - * - * Deployed by OpenTofu from this file (`bunnynet_compute_script.rpkgs_router`). - * Test with `just edge-test`. - */ -import * as BunnySDK from 'https://esm.sh/@bunny.net/edgescript-sdk@0.12'; - -const PUBLIC_CDN_ORIGIN = 'https://cran.rpkgs.com'; -const CRAN_ORIGIN = 'https://cran.r-project.org'; -const PUBLIC_CDN_HOSTS = new Set([ - 'cran.rpkgs.com', - 'cran.allianceswisspass.devxy.io', - // Staging hostnames, so the identical script can run on a test pull zone and - // redirect within itself. Without this a test zone rewrites to - // PUBLIC_CDN_ORIGIN, quietly exercising production instead of itself. - ...(Deno.env.get('EXTRA_PUBLIC_HOSTS') ?? '') - .split(',') - .map((host) => host.trim()) - .filter((host) => host.length > 0), -]); - -/** Slots ("/", comma separated) whose per-minor index is a union. */ -const UNION_SLOTS = new Set( - (Deno.env.get('UNION_SLOTS') ?? '') - .split(',') - .map((slot) => slot.trim()) - .filter((slot) => slot.length > 0), -); - -/** - * R minors for which a per-minor index is actually published. - * - * contribPath() has no way to probe the origin, so a minor that is not - * published here must fall back to the flat index. Routing an unlisted minor - * would send that client to a 404 and it would see no packages at all - a - * silent, total failure rather than a degraded one. - */ -const KNOWN_MINORS = new Set( - (Deno.env.get('KNOWN_MINORS') ?? '4.4,4.5,4.6') - .split(',') - .map((minor) => minor.trim()) - .filter((minor) => minor.length > 0), -); - -/** `///latest/src/contrib[/]` */ -const SLOT_PATH_REGEX = /^\/(amd64|arm64)\/([a-z0-9._-]+)\/latest\/src\/contrib\/?(.*)$/; - -/** A path that already sits in a per-minor slot, e.g. `4.5/PACKAGES.gz`. */ -const MINOR_DIR_REGEX = /^\d+\.\d+\//; - -/** The only files this script routes. */ -const INDEX_FILE_REGEX = /^PACKAGES(\.gz|\.rds)?$/; - -const SRC_CONTRIB_REGEX = /^\/src\/contrib\/(.+)$/; - -/** A binary archive URL whose upstream source counterpart CRAN can serve. */ -const ARCHIVE_TARBALL_REGEX = - /^\/(?:amd64|arm64)\/[a-z0-9._-]+\/latest\/src\/contrib\/Archive\/([^/]+)\/([^/]+\.tar\.gz)$/; - -const MACOS_BIN_REGEX = - /^\/bin\/macosx\/(big-sur-arm64|big-sur-x86_64|monterey-arm64|monterey-x86_64)\/contrib\/([0-9.]+)\/(.+)$/; - -const RHEL_REGEX = /(almalinux|rocky)[^\d]*(\d+)/i; - -const UBUNTU_REGEX = /Ubuntu ([\d.]+)/i; -const UBUNTU_CODENAME_REGEX = /Ubuntu [\d.]+;\s*codename=([a-z][a-z0-9-]*)/i; -const UBUNTU_CODENAMES: Record = { - '26.04': 'resolute', - '24.04': 'noble', - '22.04': 'jammy', -}; - -const ALPINE_REGEX = /(?:Alpine Linux(?:\s+VERSION_ID=)?|alpine-)\s*(\d+)\.(\d+)/i; - -/** - * R's own User-Agent is `R (4.5.3 x86_64-pc-linux-musl …)`; the Posit-style one - * some sites configure is `R/4.5.3 R (…)`. Both carry the minor, which is why - * per-minor routing works without the distro being identifiable. - */ -const R_MINOR_REGEXES = [/\bR\/(\d+)\.(\d+)/, /\bR \((\d+)\.(\d+)/]; - -function normalizePathname(pathname: string): string { - return pathname.replace(/\/{2,}/g, '/'); -} - -function redirectTo(location: string, status = 302): Response { - return new Response(null, { - status, - headers: { - Location: location, - // The target depends on the User-Agent, so the redirect itself must - // never be cached; only its target is a cacheable, UA-independent URL. - 'Cache-Control': 'no-store', - 'X-Via': 'MyMiddleware', - 'X-Rewritten-By': 'rpkgs-edge-middleware', - }, - }); -} - -function publicCdnOrigin(url: URL): string { - return PUBLIC_CDN_HOSTS.has(url.hostname) ? url.origin : PUBLIC_CDN_ORIGIN; -} - -/** - * True when the client reports an R minor that we deliberately do not serve. - * - * A client that reports no minor at all is not "unsupported": non-R fetchers - * (mirror scripts, image builds) must keep getting the flat slot. Only a - * known-and-excluded minor falls through to CRAN. - */ -function isExcludedMinor(userAgent: string): boolean { - const rMinor = extractRMinor(userAgent); - return rMinor !== null && !KNOWN_MINORS.has(rMinor); -} - -function extractRMinor(userAgent: string): string | null { - for (const regex of R_MINOR_REGEXES) { - const match = userAgent.match(regex); - if (match) { - return `${match[1]}.${match[2]}`; - } - } - return null; -} - -function mapArch(arch: string): string { - if (arch === 'aarch64') return 'arm64'; - if (arch === 'x86_64') return 'amd64'; - return arch; -} - -function extractArch(userAgent: string): string { - const match = userAgent.match(/(x86_64|aarch64|arm64|i386|i686)/); - return match ? mapArch(match[1]) : ''; -} - -/** - * Identify the `/` slot from the User-Agent, or null. - * - * A stock R User-Agent carries only `linux-gnu` / `linux-musl`, which are not - * slot names: returning them produced redirects into slots that do not exist - * (`/amd64/linux-musl/latest/…`, a guaranteed 404). An unidentifiable distro - * is reported as such so the caller can fall back to CRAN. - */ -function parseSlot(userAgent: string): string | null { - const arch = extractArch(userAgent); - if (!arch) { - return null; - } - - const rhel = userAgent.match(RHEL_REGEX); - if (rhel) { - return `${arch}/rhel${rhel[2]}`; - } - - const ubuntu = userAgent.match(UBUNTU_REGEX); - if (ubuntu) { - const codenameMatch = userAgent.match(UBUNTU_CODENAME_REGEX); - if (codenameMatch) { - return `${arch}/${codenameMatch[1].toLowerCase()}`; - } - - const codename = UBUNTU_CODENAMES[ubuntu[1]]; - if (codename) { - return `${arch}/${codename}`; - } - } - - const alpine = userAgent.match(ALPINE_REGEX); - if (alpine) { - return `${arch}/alpine${alpine[1]}${alpine[2]}`; - } - - return null; -} - -function parseMacUserAgent(userAgent: string): { os: string; arch: string; rver: string } | null { - const rverMatch = userAgent.match(/R \((\d+)\.(\d+)/); - const archMatch = userAgent.match(/(aarch64|arm64|x86_64)/); - const osMatch = userAgent.match(/darwin(\d+)/); - - if (!rverMatch || !archMatch || !osMatch) { - return null; - } - - const arch = archMatch[1] === 'aarch64' ? 'arm64' : archMatch[1]; - const darwinVer = parseInt(osMatch[1], 10); - const os = darwinVer >= 21 && darwinVer < 22 ? `monterey-${arch}` : `big-sur-${arch}`; - - return { os, arch, rver: `${rverMatch[1]}.${rverMatch[2]}` }; -} - -/** - * The contrib path a request should be served from, relative to the slot. - * - * Returns the per-minor path for an index file when the slot is known to carry - * a union index and the client's R minor is one we publish; otherwise the flat - * path, which is what every client sees today. - */ -function contribPath(slot: string, rest: string, userAgent: string): string { - const flat = rest ? `/${slot}/latest/src/contrib/${rest}` : `/${slot}/latest/src/contrib`; - - if (!INDEX_FILE_REGEX.test(rest) || !UNION_SLOTS.has(slot)) { - return flat; - } - - const rMinor = extractRMinor(userAgent); - return rMinor && KNOWN_MINORS.has(rMinor) ? `/${slot}/latest/src/contrib/${rMinor}/${rest}` : flat; -} - -BunnySDK.net.http - .servePullZone({ url: 'https://cran.rpkgs.com/' }) - .onOriginRequest((ctx) => { - const url = new URL(ctx.request.url); - const path = normalizePathname(url.pathname); - const userAgent = ctx.request.headers.get('User-Agent') || ''; - const publicOrigin = publicCdnOrigin(url); - - // macOS clients are served from CRAN's own binary tree. - const srcContrib = path.match(SRC_CONTRIB_REGEX); - if (srcContrib && /darwin/.test(userAgent)) { - const mac = parseMacUserAgent(userAgent); - if (mac) { - return Promise.resolve(redirectTo(`${publicOrigin}/bin/macosx/${mac.os}/contrib/${mac.rver}/${srcContrib[1]}`)); - } - } - - if (MACOS_BIN_REGEX.test(path)) { - return Promise.resolve(redirectTo(`${CRAN_ORIGIN}${path}`)); - } - - // Already-qualified slot URLs: what the runtime images have baked in. - const slotPath = path.match(SLOT_PATH_REGEX); - if (slotPath) { - const slot = `${slotPath[1]}/${slotPath[2]}`; - const rest = slotPath[3]; - - // Never rewrite a request that is already in a per-minor slot, or the - // redirect would chase its own tail. - if (MINOR_DIR_REGEX.test(rest)) { - return Promise.resolve(ctx.request); - } - - // An R minor outside the supported window has no binaries we can safely - // serve, so the whole interaction goes to CRAN: the index and the - // tarballs R will resolve against it. Serving the index from CRAN but - // tarballs from here would hand R a binary where it expects a source - // tarball, which fails in a far more confusing way than not being - // served at all. - if (UNION_SLOTS.has(slot) && isExcludedMinor(userAgent)) { - return Promise.resolve(redirectTo(`${CRAN_ORIGIN}/src/contrib/${rest}`)); - } - - const target = contribPath(slot, rest, userAgent); - if (target === path) { - return Promise.resolve(ctx.request); - } - return Promise.resolve(redirectTo(`${publicOrigin}${target}`)); - } - - // The bare `https://cran.rpkgs.com` form, resolved from the User-Agent. - if (path === '/' || path === '/src/contrib' || path.startsWith('/src/contrib/')) { - const slot = parseSlot(userAgent); - if (!slot) { - return Promise.resolve(redirectTo(`${CRAN_ORIGIN}${path}`)); - } - - if (UNION_SLOTS.has(slot) && isExcludedMinor(userAgent)) { - return Promise.resolve(redirectTo(`${CRAN_ORIGIN}${path}`)); - } - - const rest = srcContrib ? srcContrib[1] : ''; - return Promise.resolve(redirectTo(`${publicOrigin}${contribPath(slot, rest, userAgent)}`)); - } - - return Promise.resolve(ctx.request); - }) - .onOriginResponse(async (ctx) => { - const path = normalizePathname(new URL(ctx.request.url).pathname); - const archive = path.match(ARCHIVE_TARBALL_REGEX); - - // Binary archives can be incomplete when an older build never succeeded. - // Preserve renv/remotes version restores by falling back to CRAN's source - // package only for an absent archived tarball. A requested version can be - // either archived upstream or still current, so probe the archive first. - // Other 404s remain visible. - if (ctx.response.status === 404 && archive) { - const archiveUrl = `${CRAN_ORIGIN}/src/contrib/Archive/${archive[1]}/${archive[2]}`; - const archiveResponse = await fetch(archiveUrl, { method: 'HEAD' }); - const sourceUrl = archiveResponse.ok ? archiveUrl : `${CRAN_ORIGIN}/src/contrib/${archive[2]}`; - return redirectTo(sourceUrl); - } - - ctx.response.headers.append('X-Via', 'MyMiddleware'); - return Promise.resolve(ctx.response); - }); diff --git a/justfile b/justfile index 524ac9a..a9a705e 100644 --- a/justfile +++ b/justfile @@ -73,32 +73,3 @@ rebuild os tag arch package *versions: --build-arg CACHEBUST="$(date +%s)" \ -f docker/build-one.Dockerfile \ local - -# run the edge middleware routing matrix (uses a local deno, else the deno image) -edge-test: - #!/usr/bin/env bash - set -euo pipefail - if command -v deno >/dev/null 2>&1; then - deno test -A edge/rpkgs-router.test.ts - else - docker run --rm \ - -v "$PWD:/w" -w /w \ - -v deno-cache:/deno-dir \ - denoland/deno:latest \ - deno test -A edge/rpkgs-router.test.ts - fi - -# fail if any pipeline still targets a slot the edge does not route -check-slots: - Rscript local/check-slots.R - -# strip a retired slot from .crow/*.yaml (dry run; pass --apply to write) -retire-slot codename *flags: - Rscript local/retire-slot.R {{ codename }} --config-only {{ flags }} - -# Format or lint selected R files, defaulting to local tooling. -format *files="local": - air format {{ files }} - -fix *files="local": - jarl check --fix {{ files }} diff --git a/local/backfill-abi-classification.R b/local/backfill-abi-classification.R deleted file mode 100644 index 0f48392..0000000 --- a/local/backfill-abi-classification.R +++ /dev/null @@ -1,129 +0,0 @@ -### Classify every compiled package in a slot's flat index, into the ABI cache. -# -# Usage: Rscript local/backfill-abi-classification.R [limit] -# -# The per-minor scheme only ever classified packages that passed through the -# build pipeline after it existed. Everything built before that, and not updated -# on CRAN since, has no verdict at all -- and bincraft's risky set is what -# decides whether a flat binary may be carried into a per-minor index. With no -# verdict the answer defaults to "carry it", which is how a base64enc built -# under R 4.4.3 reached R 4.6 clients and died at dyn.load() on SETLENGTH. -# -# The verdict is a property of the source, so it is independent of OS, arch and -# R minor: one slot's backfill populates the cache for every slot. The slot -# argument only selects which flat index to enumerate. -# -# Only packages with NeedsCompilation=yes are classified. A package with no -# compiled code has no symbols to bind and cannot fail this way, so downloading -# its source to prove that would be wasted work. -# -# Resumable: `classify_r_minor_sensitive()` returns cached verdicts without -# touching the network, so a re-run costs one query per package and only the -# unclassified remainder is downloaded. - -options(error = function() { - cat("ERROR:", geterrmessage(), "\n", file = stdout()) - traceback(2) - q(status = 1) -}) - -library(bincraft, quietly = TRUE) - -args <- commandArgs(trailingOnly = TRUE) -if (length(args) < 2L) { - stop( - "usage: backfill-abi-classification.R [limit]", - call. = FALSE - ) -} -arch <- args[[1L]] -codename <- args[[2L]] -limit <- if (length(args) >= 3L) as.integer(args[[3L]]) else NA_integer_ - -db <- list( - metadata_db_type = "postgres", - metadata_db_host = "r-binaries.devxy.io", - metadata_db_name = "build_metadata", - metadata_db_port = 15432L, - metadata_db_user = "rpkgs", - metadata_db_password = Sys.getenv("PGPASS"), - metadata_db_sslmode = "require" -) - -read_index <- function(index_url) { - con <- gzcon(url(index_url, open = "rb")) - on.exit(close(con), add = TRUE) - read.dcf(con, fields = c("Package", "Version", "Built")) -} - -flat <- read_index(sprintf( - "https://cran.rpkgs.com/%s/%s/latest/src/contrib/PACKAGES.gz", - arch, - codename -)) -# Source fallbacks have no binary to be unsafe, so skip them. -flat <- flat[!is.na(flat[, "Built"]), , drop = FALSE] - -cran <- read.dcf( - gzcon(url( - "https://cloud.r-project.org/src/contrib/PACKAGES.gz", - open = "rb" - )), - fields = c("Package", "NeedsCompilation") -) -compiled <- cran[ - !is.na(cran[, "NeedsCompilation"]) & cran[, "NeedsCompilation"] == "yes", - "Package" -] - -todo <- flat[flat[, "Package"] %in% compiled, , drop = FALSE] -cat(sprintf( - "%s/%s: %d binaries in the flat index, %d compiled and due classification\n", - arch, - codename, - nrow(flat), - nrow(todo) -)) -if (!is.na(limit) && limit > 0L && limit < nrow(todo)) { - todo <- todo[seq_len(limit), , drop = FALSE] - cat(sprintf("Limited to the first %d\n", limit)) -} - -sensitive <- 0L -failed <- 0L -for (i in seq_len(nrow(todo))) { - pkg <- todo[i, "Package"] - ver <- todo[i, "Version"] - verdict <- tryCatch( - do.call( - bincraft::classify_r_minor_sensitive, - c(list(package_name = pkg, tag = ver), db) - ), - error = function(e) { - cat(sprintf(" %s %s: %s\n", pkg, ver, conditionMessage(e))) - NA - } - ) - if (is.na(verdict)) { - failed <- failed + 1L - } else if (isTRUE(verdict)) { - sensitive <- sensitive + 1L - } - if (i %% 250L == 0L) { - cat(sprintf( - " classified %d/%d (%d r-minor-sensitive, %d failed)\n", - i, - nrow(todo), - sensitive, - failed - )) - utils::flush.console() - } -} - -cat(sprintf( - "Done: %d classified, %d r-minor-sensitive, %d failed\n", - nrow(todo), - sensitive, - failed -)) diff --git a/local/backfill-flat-safety.R b/local/backfill-flat-safety.R deleted file mode 100644 index 59faa3c..0000000 --- a/local/backfill-flat-safety.R +++ /dev/null @@ -1,199 +0,0 @@ -### Record, per flat-slot object, whether it loads under every supported R minor. -# -# Usage: Rscript local/backfill-flat-safety.R [limit] -# -# `union_index_records()` keeps a carried flat record when that object is -# recorded safe, and drops it otherwise. Objects uploaded before that record -# existed have no verdict, so they are dropped: a full re-index removed ~2000 -# records per per-minor index, all resolving to CRAN source for clients the -# routing serves correctly. -# -# This inspects the objects already in the bucket and writes the verdict, which -# is what restores that coverage. The question is decided by symbols, not -# heuristics: a binary fails at dyn.load() under minor M exactly when it -# references a symbol M does not export, and the volatile set is derived by -# diffing the R builds installed in this image. -# -# Only compiled packages are inspected. A package with no shared object cannot -# fail this way and is never dropped, so proving it would be wasted downloads. -# -# Resumable: objects already recorded are skipped, so a re-run costs one query. - -options(error = function() { - cat("ERROR:", geterrmessage(), "\n", file = stdout()) - traceback(2) - q(status = 1) -}) - -library(bincraft, quietly = TRUE) - -args <- commandArgs(trailingOnly = TRUE) -if (length(args) < 2L) { - stop("usage: backfill-flat-safety.R [limit]", call. = FALSE) -} -arch <- args[[1L]] -codename <- args[[2L]] -limit <- if (length(args) >= 3L) as.integer(args[[3L]]) else NA_integer_ - -sets <- bincraft:::installed_r_symbol_sets() -if (is.null(sets)) { - stop( - "Fewer than two R minors are installed here, so nothing can be compared. Refusing to record verdicts that would all read 'safe'.", # nolint - call. = FALSE - ) -} -cat(sprintf( - "Comparing R minors: %s (%d symbols vary across them)\n", - paste(names(sets), collapse = ", "), - length(bincraft:::cross_minor_volatile_symbols(sets)) -)) - -# The flat slot is listed from S3, not from PACKAGES.gz over HTTPS. Reading the -# index from inside R sends an R User-Agent, which the edge routes to a -# per-minor index; that enumerates union records carrying `Path: `, whose -# objects do not exist at the flat path. Every download then 404s: a first run -# skipped 292 of 300 that way. The bucket cannot be routed and is authoritative. -s3fs::s3_file_system( - aws_access_key_id = Sys.getenv("B2_S3_ACCESS_KEY"), - aws_secret_access_key = Sys.getenv("B2_S3_SECRET_KEY"), - endpoint = "https://s3.eu-central-003.backblazeb2.com", - region_name = "eu-central-003", - refresh = TRUE -) -contrib <- sprintf( - "devxy-rpkgs-binaries/%s/%s/latest/src/contrib", - arch, - codename -) -objects <- s3fs::s3_dir_ls(contrib, recurse = TRUE) -relative <- sub(sprintf("^(s3://)?%s/", contrib), "", objects) -# Flat objects only: no `/` prefix and nothing under Archive/. -flat_files <- relative[ - grepl("\\.tar\\.gz$", relative) & !grepl("/", relative, fixed = TRUE) -] -flat_pkg <- sub("_.*$", "", flat_files) -flat_ver <- sub("\\.tar\\.gz$", "", sub("^[^_]*_", "", flat_files)) - -# Only compiled packages can fail at dyn.load(), so the rest are not worth -# downloading. Ask the slot's own index rather than CRAN's: CRAN lists only the -# versions it currently ships, so a package it has archived is absent, is never -# recognised as compiled, and is never inspected. No verdict is ever recorded -# for it, while `union_index_records()` reads the slot's own `NeedsCompilation` -# and happily drops it -- with nothing able to clear the flag, because the -# repair works from recorded verdicts too. `mixedMem` and `vcpen`, both removed -# from CRAN, were stuck out of every rhel8 per-minor index that way. -# -# Read it from the bucket, not over HTTPS: this runs inside R, so the edge would -# route the request to a per-minor index and the flat objects would not all be -# there. Same reason the object listing above comes from S3. -index_file <- file.path(tempdir(), "flat-PACKAGES.gz") -s3fs::s3_file_download( - sprintf("%s/PACKAGES.gz", contrib), - index_file, - overwrite = TRUE -) -index <- read.dcf( - gzfile(index_file), - fields = c("Package", "Version", "NeedsCompilation") -) -if (nrow(index) == 0L) { - stop( - sprintf( - "Read no records from %s/PACKAGES.gz; refusing to guess what is compiled.", - contrib - ), - call. = FALSE - ) -} -# Key on the object, not the package: a slot can carry several versions and -# only some of them compile. -compiled_objects <- paste( - index[, "Package"], - index[, "Version"], - sep = "_" -)[ - !is.na(index[, "NeedsCompilation"]) & - tolower(index[, "NeedsCompilation"]) == "yes" -] -keep <- paste(flat_pkg, flat_ver, sep = "_") %in% compiled_objects -compiled <- data.frame( - Package = flat_pkg[keep], - Version = flat_ver[keep], - stringsAsFactors = FALSE -) -cat(sprintf( - "%s/%s: %d flat objects, %d compiled and worth inspecting\n", - arch, - codename, - length(flat_files), - nrow(compiled) -)) -if (!is.na(limit) && limit > 0L && limit < nrow(compiled)) { - compiled <- compiled[seq_len(limit), , drop = FALSE] - cat(sprintf("Limited to the first %d\n", limit)) -} - -# Fetch from the bucket as well: the edge does not rewrite tarball requests, -# but it does cache them, and the object is what the verdict is about. -safe <- 0L -unsafe <- 0L -skipped <- 0L -for (i in seq_len(nrow(compiled))) { - pkg <- compiled[i, "Package"] - ver <- compiled[i, "Version"] - tarball <- file.path(tempdir(), sprintf("%s_%s.tar.gz", pkg, ver)) - ok <- tryCatch( - { - s3fs::s3_file_download( - sprintf("%s/%s_%s.tar.gz", contrib, pkg, ver), - tarball, - overwrite = TRUE - ) - file.exists(tarball) - }, - error = function(e) FALSE, - warning = function(w) FALSE - ) - if (!ok) { - skipped <- skipped + 1L - next - } - verdict <- bincraft:::tarball_abi_verdict(tarball, sets) - unlink(tarball) - if (!isTRUE(verdict$inspected)) { - skipped <- skipped + 1L - next - } - bincraft:::flat_safety_store( - pkg, - ver, - codename, - arch, - safe = !isTRUE(verdict$sensitive), - unsupported = verdict$unsupported - ) - if (isTRUE(verdict$sensitive)) { - unsafe <- unsafe + 1L - } else { - safe <- safe + 1L - } - if (i %% 250L == 0L) { - cat(sprintf( - " inspected %d/%d (%d safe, %d unsafe, %d skipped)\n", - i, - nrow(compiled), - safe, - unsafe, - skipped - )) - utils::flush.console() - } -} - -cat(sprintf( - "Done: %d inspected, %d safe for every minor, %d unsafe, %d skipped\n", - nrow(compiled), - safe, - unsafe, - skipped -)) diff --git a/local/build-all.R b/local/build-all.R index 349092f..1df1f83 100644 --- a/local/build-all.R +++ b/local/build-all.R @@ -26,51 +26,8 @@ package_cache_files <- c( "/mnt/cache/packages/r_minor_sensitive_pkgs.rds", "/mnt/cache/packages/s3_cache.rds" ) -# Existence is not freshness. The snapshot describes S3 and CRAN state at the -# moment it was written, and the volume is per-agent, so an agent that ran an -# earlier pipeline keeps serving that pipeline's answer forever. arm64/alpine324 -# reported "0 remaining" for both 4.4 and 4.6 from a stale snapshot listing 43 -# sensitive packages, while the install-deps step in the very same pipeline had -# just computed 6871 on another agent. -# -# Keyed on the pipeline when the CI exposes one, so a new pipeline recomputes -# once per agent and its shards then share the result. Off CI, or when no such -# variable is set, fall back to an age check. -snapshot_id_path <- "/mnt/cache/packages/snapshot.id" -snapshot_ttl_hours <- as.numeric( - Sys.getenv("PACKAGE_SNAPSHOT_TTL_HOURS", unset = "2") -) -current_snapshot_id <- "" -for (v in c("CI_PIPELINE_NUMBER", "CI_BUILD_NUMBER", "CI_PIPELINE_ID")) { - val <- Sys.getenv(v, unset = "") - if (nzchar(val)) { - current_snapshot_id <- paste(v, val, sep = "=") - break - } -} - -snapshot_is_stale <- function() { - if (!all(file.exists(package_cache_files))) { - return(TRUE) - } - if (nzchar(current_snapshot_id)) { - cached <- tryCatch( - readLines(snapshot_id_path, warn = FALSE)[1L], - error = function(e) NA_character_, - warning = function(w) NA_character_ - ) - return(!identical(cached, current_snapshot_id)) - } - age_hours <- as.numeric( - difftime(Sys.time(), file.mtime(package_cache_files[1L]), units = "hours") - ) - isTRUE(age_hours > snapshot_ttl_hours) -} - -if (snapshot_is_stale()) { - message( - "Package snapshot missing or stale; recomputing via packages-to-build.R" - ) +if (!all(file.exists(package_cache_files))) { + message("Package snapshot missing from cache; recomputing via packages-to-build.R") dir.create("/mnt/cache/packages", showWarnings = FALSE, recursive = TRUE) save_rds_atomic <- function(obj, path) { tmp <- paste0(path, ".tmp.", Sys.getpid()) @@ -79,13 +36,7 @@ if (snapshot_is_stale()) { } source(file.path("local", "packages-to-build.R")) save_rds_atomic(pkgs, "/mnt/cache/packages/pkgs_to_build.rds") - save_rds_atomic( - pkgs[r_minor_sensitive == TRUE], - "/mnt/cache/packages/r_minor_sensitive_pkgs.rds" - ) - if (nzchar(current_snapshot_id)) { - writeLines(current_snapshot_id, snapshot_id_path) - } + save_rds_atomic(pkgs[r_minor_sensitive == TRUE], "/mnt/cache/packages/r_minor_sensitive_pkgs.rds") message("Package snapshot recomputed.") } @@ -154,84 +105,27 @@ con <- DBI::dbConnect( password = Sys.getenv("PGPASS"), sslmode = "require" ) -# Scope the skip to the R minor this pass is running under. `single_builds` -# records `r_version` per attempt, but querying without it made a non-primary -# pass skip everything the primary pass had already attempted under a different -# minor - so `--sensitive-only` under 4.6 skipped packages that had only ever -# been built for 4.5, and the per-minor slots never filled. That is why -# amd64/resolute served 4000 fewer packages to a 4.6 client than to a 4.5 one. -r_minor <- paste( - R.version$major, - strsplit(R.version$minor, ".", fixed = TRUE)[[1L]][1L], - sep = "." -) built <- DBI::dbGetQuery( con, - paste( - "SELECT name, tag FROM single_builds", - "WHERE platform = $1 AND arch = $2", - "AND substring(r_version from '^[0-9]+[.][0-9]+') = $3" - ), - params = list(platform, arch, r_minor) + "SELECT name, tag FROM single_builds WHERE platform = $1 AND arch = $2", + params = list(platform, arch) ) DBI::dbDisconnect(con) before <- nrow(chunk) -chunk <- chunk[ - !paste(chunk$Package, chunk$Version) %in% paste(built$name, built$tag), -] -sprintf( - "Skipped %d package versions already attempted under R %s; %d remaining for this job", - before - nrow(chunk), - r_minor, - nrow(chunk) -) +chunk <- chunk[!paste(chunk$Package, chunk$Version) %in% paste(built$name, built$tag), ] +sprintf("Skipped %d already-attempted package versions; %d remaining for this job", before - nrow(chunk), nrow(chunk)) # Read pre-computed S3 listing from install-deps step # This avoids loading s3fs/reticulate/Python in the build container, -# saving significant memory for the dependency-installer subprocesses -s3_cache_paths <- readRDS("/mnt/cache/packages/s3_cache.rds") - -# The cache is stored as paths relative to the slot, so a pass can select the -# objects that belong to it. `build_binary_package()` compares basenames, which -# cannot distinguish `4.4/curl_1.0.tar.gz` from `curl_1.0.tar.gz`, so the choice -# has to be made here where the running R minor is known. -# -# Getting this wrong is expensive in both directions: hand it everything and a -# per-minor pass believes the flat slot's binaries are its own and builds -# nothing; hand it nothing and it rebuilds what it already has. amd64/resolute -# recompiled 8683 packages that way. -select_cache_for_pass <- function(paths, sensitive_only, r_minor) { - # A cache written before #191 holds bare basenames. Filtering those by path - # would select nothing and trigger a full rebuild, so use them as they are; - # #190's staleness check replaces it on the next pipeline anyway. - # - # The format is read from the marker `packages-to-build.R` sets, not guessed - # from the content: a slot-relative cache with no per-minor or Archive object - # holds bare names too, and treating that as legacy would hand a per-minor - # pass the flat slot's binaries and build nothing. - if (!isTRUE(attr(paths, "slot_relative"))) { - message("S3 cache is in the legacy basename format; using it unfiltered.") - return(paths) - } - in_minor <- grepl(sprintf("^%s/", r_minor), paths) - if (sensitive_only) { - basename(paths[in_minor]) - } else { - # The primary pass writes the flat slot. Archive/ counts as present there: - # those are versions built and later superseded. - basename(paths[!grepl("^[0-9]+\\.[0-9]+/", paths)]) - } -} - -s3_cache <- select_cache_for_pass(s3_cache_paths, sensitive_only, r_minor) -sprintf( - "S3 cache: %s files (%s of %s objects apply to this pass)", - length(s3_cache), - length(s3_cache), - length(s3_cache_paths) -) +# saving significant memory for pak subprocess forks +s3_cache <- readRDS("/mnt/cache/packages/s3_cache.rds") +sprintf("S3 cache: %s files", length(s3_cache)) n <- nrow(chunk) +# Every `trim_every` packages, bound the pkgcache _metadata dir so a full-platform +# run does not accumulate thousands of ~70 MB snapshots and fill the host disk. +# No-op on amd64 (R_PKG_CACHE_DIR is empty / cache not persisted). +trim_every <- 25L mapply( function(pkg, ver, sens, i) { cat(sprintf("[%d/%d] %s_%s (r_minor_sensitive=%s)\n", i, n, pkg, ver, sens)) @@ -257,6 +151,12 @@ mapply( upload = TRUE, store_build_metadata = TRUE ) + if (i %% trim_every == 0L) { + removed <- trim_pkgcache_metadata() + if (removed > 0L) { + cat(sprintf(" [pkgcache trim] removed %d stale _metadata entries\n", removed)) + } + } }, chunk$Package, chunk$Version, diff --git a/local/build-newer-r.R b/local/build-newer-r.R deleted file mode 100644 index 3d7794f..0000000 --- a/local/build-newer-r.R +++ /dev/null @@ -1,72 +0,0 @@ -source(file.path("local", "newer-r-helpers.R")) -source(file.path("local", "rebuild-missing-helpers.R")) - -args <- commandArgs(trailingOnly = TRUE) -pos <- args[!startsWith(args, "--")] -if (!length(pos) %in% c(1L, 3L)) { - stop("Usage: build-newer-r.R PRIMARY_R [SPLIT_INTO SPLIT_INDEX] [--dry-run]") -} -primary_r <- pos[1L] -target_r <- as.character(getRversion()) -pkgs <- newer_r_candidates(tools::CRAN_package_db(), primary_r, target_r) -exclude <- jsonlite::fromJSON("local/excluded-packages.json")[["package"]] -pkgs <- pkgs[!pkgs$Package %in% exclude, , drop = FALSE] -if (length(pos) == 3L) { - pkgs <- pkgs[ - shard_slice(seq_len(nrow(pkgs)), pos[2L], pos[3L]), - , - drop = FALSE - ] -} -message(sprintf( - "R %s: %d package(s) unsupported by primary R %s", - target_r, - nrow(pkgs), - primary_r -)) -if ("--dry-run" %in% args) { - print(pkgs, row.names = FALSE) -} else { - options(crayon.enabled = TRUE, Ncpus = 4, future.globals.onReference = NULL) - for (i in seq_len(nrow(pkgs))) { - message(sprintf( - "[%d/%d] %s %s", - i, - nrow(pkgs), - pkgs$Package[i], - pkgs$Version[i] - )) - bincraft::build_binary_package( - pkgs$Package[i], - tag = pkgs$Version[i], - # Keep bincraft's normal ABI routing: safe binaries can be shared; - # sensitive ones use this interpreter's minor for presence and output. - is_r_minor_sensitive = bincraft::classify_r_minor_sensitive( - pkgs$Package[i], - pkgs$Version[i], - metadata_db_host = "r-binaries.devxy.io", - metadata_db_name = "build_metadata", - metadata_db_user = "rpkgs", - metadata_db_password = Sys.getenv("PGPASS"), - metadata_db_sslmode = "require", - metadata_db_port = 15432 - ), - s3_endpoint = "https://s3.eu-central-003.backblazeb2.com", - s3_region = "eu-central-003", - s3_bucket = "devxy-rpkgs-binaries", - s3_access_key_id = Sys.getenv("B2_S3_ACCESS_KEY"), - s3_secret_access_key = Sys.getenv("B2_S3_SECRET_KEY"), - metadata_db_host = "r-binaries.devxy.io", - metadata_db_name = "build_metadata", - metadata_db_table = "single_builds", - metadata_db_user = "rpkgs", - metadata_db_password = Sys.getenv("PGPASS"), - metadata_db_sslmode = "require", - metadata_db_port = 15432, - archive = TRUE, - patches = "local/patches", - upload = TRUE, - store_build_metadata = TRUE - ) - } -} diff --git a/local/check-slots.R b/local/check-slots.R deleted file mode 100644 index 5345da9..0000000 --- a/local/check-slots.R +++ /dev/null @@ -1,151 +0,0 @@ -### Fail when a pipeline targets a slot that is not in `local.rpkgs_slots`. -# -# Rscript local/check-slots.R -# -# That list feeds `UNION_SLOTS`, which `edge/rpkgs-router.ts` gates per-R-minor -# routing on. An unrouted slot serves the flat index to every client, so its -# binaries fail at dyn.load() under every R minor but one. -# -# One-directional: a published slot missing from some pipeline is only -# reported, since several pipelines cover a subset on purpose. - -options(error = function() { - cat("ERROR:", geterrmessage(), "\n", file = stdout()) - q(status = 1) -}) - -# Parses the distro list out of the setproduct's second array. -published_codenames <- function(path = "cdn.tf") { - if (!file.exists(path)) { - stop( - sprintf("%s not found; run this from the repository root.", path), - call. = FALSE - ) - } - lines <- readLines(path, warn = FALSE) - start <- grep("rpkgs_slots\\s*=", lines) - if (length(start) != 1L) { - stop( - "Could not find exactly one `rpkgs_slots` local in cdn.tf.", - call. = FALSE - ) - } - block <- paste(lines[start:min(start + 10L, length(lines))], collapse = "\n") - arrays <- regmatches(block, gregexpr("\\[[^]]*\\]", block))[[1L]] - if (length(arrays) < 2L) { - stop( - "`rpkgs_slots` no longer has the two-array setproduct shape this parses.", - call. = FALSE - ) - } - codenames <- gsub( - '"', - "", - regmatches(arrays[[2L]], gregexpr('"[^"]+"', arrays[[2L]]))[[1L]] - ) - if (length(codenames) == 0L) { - stop("Parsed an empty distro list from cdn.tf.", call. = FALSE) - } - codenames -} - -UBUNTU_CODENAMES <- c( - resolute = "ubuntu-2604", - noble = "ubuntu-2404", - jammy = "ubuntu-2204" -) - -as_pipeline_os <- function(codename) { - ifelse( - codename %in% names(UBUNTU_CODENAMES), - UBUNTU_CODENAMES[codename], - ifelse( - grepl("^rhel[0-9]+$", codename), - sub("^rhel", "redhat-", codename), - ifelse( - grepl("^alpine[0-9]+$", codename), - sub("^alpine([0-9])([0-9]+)$", "alpine-\\1\\2", codename), - NA_character_ - ) - ) - ) -} - -# Structural lines only, so a codename in a comment is not a finding. -slots_in_pipeline <- function(path) { - lines <- readLines(path, warn = FALSE) - row <- regmatches( - lines, - regexec("^\\s*-?\\s*(OS|CODENAME|OS_ID):\\s*(\\S+)\\s*$", lines) - ) - opt <- regmatches( - lines, - regexec("^\\s*-\\s*([a-z0-9]+-[a-z0-9]+)-(amd64|arm64)\\s*$", lines) - ) - hits <- c( - vapply( - row, - function(m) if (length(m) == 3L) m[[3L]] else NA_character_, - "" - ), - vapply(opt, function(m) if (length(m) == 4L) m[[2L]] else NA_character_, "") - ) - hits <- hits[!is.na(hits)] - # `OS: alpine` (build-all-versions' bare image name) is not a slot. - unique(hits[grepl("^(alpine|redhat|ubuntu)-?[0-9]", hits)]) -} - -codenames <- published_codenames() -published_os <- unname(as_pipeline_os(codenames)) -if (anyNA(published_os)) { - stop( - sprintf( - "Unrecognised codename(s) in cdn.tf: %s", - paste(codenames[is.na(published_os)], collapse = ", ") - ), - call. = FALSE - ) -} -# Files spell a slot either way, so accept both. -published <- unique(c(published_os, codenames)) - -cat(sprintf( - "Published slots (cdn.tf): %s\n\n", - paste(sort(published_os), collapse = ", ") -)) - -files <- sort(list.files(".crow", pattern = "\\.yaml$", full.names = TRUE)) -findings <- list() -for (f in files) { - stale <- setdiff(slots_in_pipeline(f), published) - if (length(stale) > 0L) { - findings[[f]] <- stale - cat(sprintf( - "%-45s targets unpublished: %s\n", - f, - paste(sort(stale), collapse = ", ") - )) - } -} - -uncovered <- setdiff( - published_os, - unique(unlist(lapply(files, slots_in_pipeline))) -) -if (length(uncovered) > 0L) { - cat(sprintf( - "\nNote: %s appear(s) in no pipeline at all.\n", - paste(sort(uncovered), collapse = ", ") - )) -} - -if (length(findings) == 0L) { - cat("All pipeline slots are published and routed.\n") - quit(status = 0) -} - -cat(sprintf( - "\n%d file(s) target an unrouted slot. Retire it with:\n just retire-slot \n", - length(findings) -)) -quit(status = 1) diff --git a/local/dedupe-audit-issue.R b/local/dedupe-audit-issue.R deleted file mode 100644 index c78015a..0000000 --- a/local/dedupe-audit-issue.R +++ /dev/null @@ -1,150 +0,0 @@ -# One-off maintenance: collapse the duplicate arch subsections that accumulated -# in the "Missing package binaries for latest version ()" issues. -# -# A bug in weekly-missing-binaries-audit.R matched the existing "### " -# subsection by its bare header while writing headers with a -# " (N missing, M to rebuild)" suffix, so every audit run appended a fresh block -# instead of replacing it. This script rewrites each "## " section to -# keep only the *last* (freshest) block per arch. The audit fix prevents further -# accumulation; this cleans up what is already there. -# -# Env: FORGEJO_TOKEN (required). DRY_RUN=1 to preview counts without patching. - -library(httr2, quietly = TRUE) - -forgejo_base <- "https://git.devxy.io/api/v1" -repo <- "devxy/build-cran-binaries" -token <- Sys.getenv("FORGEJO_TOKEN") -dry_run <- nchar(Sys.getenv("DRY_RUN")) > 0 - -if (nchar(token) == 0) { - stop("FORGEJO_TOKEN env var is not set") -} - -issue_titles <- c( - "Missing package binaries for latest version (Alpine)", - "Missing package binaries for latest version (Ubuntu)", - "Missing package binaries for latest version (Red Hat)" -) - -# Collapse one "## " block: keep only the last block per arch, -# emitted in order of first appearance. `pl[1]` is the "## " header. -dedupe_platform <- function(pl) { - sub_hdr <- which(grepl("^### ", pl)) - if (length(sub_hdr) == 0) { - return(pl) - } - preamble <- pl[seq_len(sub_hdr[1] - 1)] - sub_end <- c(sub_hdr[-1] - 1, length(pl)) - blocks <- lapply(seq_along(sub_hdr), function(k) { - pl[seq(sub_hdr[k], sub_end[k])] - }) - arches <- vapply( - blocks, - function(b) sub("^### (\\S+).*", "\\1", b[1]), - character(1) - ) - # Index of the last block for each arch, kept in first-appearance order. - last_idx <- vapply( - unique(arches), - function(a) max(which(arches == a)), - integer(1) - ) - keep <- sort(last_idx) - out <- preamble - for (i in keep) { - out <- c(out, blocks[[i]]) - } - out -} - -process_issue <- function(title) { - search_url <- sprintf( - "%s/repos/%s/issues?type=issues&state=open&q=%s&limit=50", - forgejo_base, - repo, - utils::URLencode(title, reserved = TRUE) - ) - resp <- request(search_url) |> - req_headers(Authorization = paste("token", token)) |> - req_perform() - issues <- resp_body_json(resp, simplifyVector = FALSE) - match_idx <- which(vapply(issues, function(x) x$title, character(1)) == title) - if (length(match_idx) == 0) { - cat(sprintf("[skip] No issue found: %s\n", title)) - return(invisible()) - } - - issue_number <- issues[[match_idx[1]]]$number - body <- issues[[match_idx[1]]]$body - if (is.null(body) || nchar(body) == 0) { - cat(sprintf("[skip] Empty body: #%d %s\n", issue_number, title)) - return(invisible()) - } - - lines <- strsplit(body, "\n", fixed = TRUE)[[1]] - before <- sum(grepl("^### ", lines)) - - # Split off the "## Excluded packages" footer so it is preserved verbatim. - excl_idx <- which(lines == "## Excluded packages") - footer <- character(0) - if (length(excl_idx) > 0) { - pre_dash <- which(lines == "---" & seq_along(lines) < excl_idx[1]) - cut <- if (length(pre_dash) > 0) pre_dash[length(pre_dash)] else excl_idx[1] - footer <- lines[seq(cut, length(lines))] - lines <- lines[seq_len(cut - 1)] - } - - # Platform headers ("## "); everything before the first is preamble. - plat_idx <- which(grepl("^## ", lines)) - if (length(plat_idx) == 0) { - cat(sprintf("[skip] No platform sections: #%d %s\n", issue_number, title)) - return(invisible()) - } - top <- lines[seq_len(plat_idx[1] - 1)] - plat_end <- c(plat_idx[-1] - 1, length(lines)) - - new_lines <- top - for (j in seq_along(plat_idx)) { - pl <- lines[seq(plat_idx[j], plat_end[j])] - new_lines <- c(new_lines, dedupe_platform(pl)) - } - if (length(footer) > 0) { - new_lines <- c(new_lines, footer) - } - - after <- sum(grepl("^### ", new_lines)) - cat(sprintf( - "#%d %s: %d -> %d arch subsections%s\n", - issue_number, - title, - before, - after, - if (dry_run) " (dry run, not patched)" else "" - )) - - if (dry_run) { - return(invisible()) - } - - patch_url <- sprintf( - "%s/repos/%s/issues/%d", - forgejo_base, - repo, - issue_number - ) - request(patch_url) |> - req_headers( - Authorization = paste("token", token), - `Content-Type` = "application/json" - ) |> - req_body_json(list(body = paste(new_lines, collapse = "\n"))) |> - req_method("PATCH") |> - req_perform() - cat(sprintf(" patched #%d\n", issue_number)) -} - -for (t in issue_titles) { - process_issue(t) -} -cat("Done.\n") diff --git a/local/failing-builds-classify.R b/local/failing-builds-classify.R index 73922b3..30c168d 100644 --- a/local/failing-builds-classify.R +++ b/local/failing-builds-classify.R @@ -15,11 +15,6 @@ # would require a brand-new source diff for a previously-unseen package stay # `auto = FALSE` -> classified, but always routed to human triage, per the # issue's guardrail against shipping autonomous novel source diffs. -# `applies_to` (optional) pins a package-specific fix to the package it targets: -# a signature whose registry patch belongs to one package (e.g. RcppParallel's -# disable-tbb patch) is only proposed for that package. Other packages matching -# the signature are downstream failures blocked on that dependency, not -# individually patchable, so they are reported but never proposed an entry. # # Seeded from the existing registry entries and known recurring failures; add # a row here as new signatures are confirmed. Order matters: the first match @@ -50,14 +45,6 @@ build_signatures <- function() { tier = "patch", confidence = "high", auto = TRUE, - # The fix is a RcppParallel-specific source patch, so it is only ever - # proposed for RcppParallel itself. The hundreds of packages that fail - # merely because their RcppParallel *dependency* did not build carry - # RcppParallel's error text and match this signature too -- they are - # blocked on RcppParallel, not individually patchable (applying this diff - # to their source is meaningless). `applies_to` pins the proposal so those - # downstream failures are never proposed a bogus per-package entry. - applies_to = "RcppParallel", fix = "apply the curated RcppParallel/disable-tbb.patch so the bundled TBB build is skipped and the TinyThread backend is used", example = "RcppParallel", registry = list( @@ -155,44 +142,6 @@ fingerprint_error <- function(error_text, package = NULL, max_chars = 200L) { fp } -# --------------------------------------------------------------------------- -# Dependency-cascade detection -# --------------------------------------------------------------------------- -# If a build's error_text shows the failure was actually in a DIFFERENT package -# (a dependency that would not compile), return that dependency's name; -# otherwise NA. Used to avoid proposing a per-package fix for a package that -# only fails because a shared dependency does not build (e.g. the ~73 Stan -# packages that fail while building `rstan`). Generalises the RcppParallel -# `applies_to` guard to any dependency named in the log. -failing_dependency <- function(error_text, package) { - if (length(error_text) == 0L || is.na(error_text) || !nzchar(error_text)) { - return(NA_character_) - } - x <- as.character(error_text) - # optional opening quote before the package name: apostrophe, double-quote, - # backtick, or curly quotes -- written as \u escapes so the pattern stays - # valid UTF-8 regardless of source encoding. - q <- "[\u0027\u0022\u0060\u2018\u2019]?" - name <- "([A-Za-z][A-Za-z0-9._]+)" - # Markers R/pak emit naming the package that actually failed to compile. - pats <- c( - paste0("compilation failed for package ", q, name), - paste0("Failed to build source package ", q, name), - paste0("Error in building package ", q, name), - paste0("dependenc(?:y|ies) ", q, name, q, "?[^\\n]*not available") - ) - deps <- character(0L) - for (p in pats) { - hits <- regmatches(x, gregexpr(p, x, perl = TRUE))[[1L]] - if (length(hits) > 0L) { - deps <- c(deps, sub(p, "\\1", hits, perl = TRUE)) - } - } - deps <- sub("[._]+$", "", deps) # drop a trailing sentence period (e.g. "rstan.") - deps <- setdiff(unique(deps), package) # a package failing on its OWN code is not a cascade - if (length(deps) == 0L) NA_character_ else deps[[1L]] -} - # --------------------------------------------------------------------------- # Classification # --------------------------------------------------------------------------- @@ -329,43 +278,9 @@ build_triage_report <- function( unregistered <- setdiff(pkgs, registered_pkgs) auto_proposable <- isTRUE(sig$auto) && sig$matched - # Decide, per package, whether it is genuinely fixable or merely blocked on - # a dependency (so a per-package entry would be useless). Two blocking modes: - # 1. `applies_to`: a package-specific patch (e.g. RcppParallel's) is only - # valid for its own package; other matches are downstream of it. - # 2. data-driven cascade: the package's error_text shows a *different* - # package failed to compile (e.g. the ~73 Stan packages blocked on rstan). - # `blocked_map` maps a blocked package -> the dependency it waits on. - g$blocked_dep <- vapply( - seq_len(nrow(g)), - function(i) failing_dependency(g$error_text[[i]], g$name[[i]]), - character(1L) - ) - proposable <- unregistered - blocked_map <- list() - if (!is.null(sig$applies_to)) { - for (p in setdiff(pkgs, sig$applies_to)) { - blocked_map[[p]] <- sig$applies_to - } - proposable <- intersect(proposable, sig$applies_to) - } - for (p in proposable) { - deps <- g$blocked_dep[g$name == p] - if (!any(is.na(deps))) { - # every failing build of p is a cascade -> blocked, not fixable here - blocked_map[[p]] <- unique(deps) - } - } - proposable <- setdiff(proposable, names(blocked_map)) - blocked_packages <- names(blocked_map) - blocked_on <- unique(unlist(blocked_map, use.names = FALSE)) - if (length(blocked_on) == 0L) { - blocked_on <- NULL - } - proposed <- list() if (auto_proposable) { - for (p in proposable) { + for (p in unregistered) { proposed[[p]] <- propose_registry_entry_list( sig, p, @@ -382,10 +297,6 @@ build_triage_report <- function( tier = sig$tier, confidence = sig$confidence, suggested_fix = sig$fix, - applies_to = sig$applies_to, - blocked_on = blocked_on, - blocked_packages = blocked_packages, - blocked_map = if (length(blocked_map) > 0L) blocked_map else NULL, fingerprint = names(fp_tab)[[1L]], fingerprint_variants = length(fp_tab), build_count = nrow(g), diff --git a/local/failing-builds-report.R b/local/failing-builds-report.R index ce7b683..60f65ea 100644 --- a/local/failing-builds-report.R +++ b/local/failing-builds-report.R @@ -194,18 +194,9 @@ for (r in report) { ) cat(paste0(" ", gsub("\n", "\n ", j)), "\n", sep = "") } - } else if (r$auto_proposable && length(r$blocked_packages) == 0L) { + } else if (r$auto_proposable) { cat(" (all affected packages already have a registry entry)\n") } - # Blocked packages are shown even when the group also has proposals. - if (length(r$blocked_packages) > 0L) { - cat(sprintf( - " (blocked on %s -- %d package(s) fail because that dependency does not build; fix %s, do not patch each dependent)\n", - toString(r$blocked_on), - length(r$blocked_packages), - toString(r$blocked_on) - )) - } } # --------------------------------------------------------------------------- diff --git a/local/fetch-rebuild-packages-from-issue.R b/local/fetch-rebuild-packages-from-issue.R index 9d8043c..26e544d 100644 --- a/local/fetch-rebuild-packages-from-issue.R +++ b/local/fetch-rebuild-packages-from-issue.R @@ -4,6 +4,7 @@ forgejo_base <- "https://git.devxy.io/api/v1" repo <- "devxy/build-cran-binaries" platform <- Sys.getenv("PLATFORM") arch <- Sys.getenv("ARCH") +token <- Sys.getenv("FORGEJO_TOKEN") output_file <- Sys.getenv("REBUILD_PKG_LIST", "/tmp/rebuild_pkgs.txt") if (nchar(platform) == 0) { @@ -12,36 +13,6 @@ if (nchar(platform) == 0) { if (nchar(arch) == 0) { stop("ARCH env var is not set") } - -# Prefer the audit's freshly-written RDS. The audit overwrites it each run -# (saveRDS), so unlike the Forgejo issue body it is never subject to the -# duplicate-subsection accumulation bug. Fall back to parsing the issue when the -# RDS is absent (e.g. a fresh runner with no shared cache). -rds_file <- file.path( - Sys.getenv("REBUILD_PKG_RDS_DIR", "/mnt/cache/packages"), - sprintf("weekly_rebuild_%s_%s.rds", platform, arch) -) -if (file.exists(rds_file)) { - pkgs <- tryCatch(as.character(readRDS(rds_file)), error = function(e) NULL) - if (!is.null(pkgs) && length(pkgs) > 0) { - cat(sprintf( - "Using audit RDS %s: %d rebuildable packages for %s/%s\n", - rds_file, - length(pkgs), - platform, - arch - )) - writeLines(pkgs, output_file) - cat(sprintf("Wrote package list to %s\n", output_file)) - q("no") - } - cat(sprintf( - "RDS %s present but empty/unreadable -- falling back to issue\n", - rds_file - )) -} - -token <- Sys.getenv("FORGEJO_TOKEN") if (nchar(token) == 0) { stop("FORGEJO_TOKEN env var is not set") } diff --git a/local/install-bincraft.R b/local/install-bincraft.R index e39cb4f..4665b3b 100644 --- a/local/install-bincraft.R +++ b/local/install-bincraft.R @@ -11,11 +11,10 @@ # # How it works: list the remote tags with `git ls-remote` (no token needed for # the public repo), keep the `vX.Y.Z` release tags, pick the highest version, -# and install it with uvr via `local/uvr-install.sh`. uvr is idempotent on the -# git ref, so re-running keeps the package when it is already current and only -# updates when a newer tag ships. Filtering/sorting is done in R (not via git's -# `--sort`/refspec) so behaviour is identical across git versions and -# `system2()` argument handling. +# and install it with pak. pak is idempotent on the git ref, so re-running keeps +# the package when it is already current and only updates when a newer tag ships. +# Filtering/sorting is done in R (not via git's `--sort`/refspec) so behaviour is +# identical across git versions and `system2()` argument handling. repo_url <- Sys.getenv( "BINCRAFT_GIT_URL", @@ -45,43 +44,7 @@ latest <- tags[order(package_version(sub("^v", "", tags)), decreasing = TRUE)][ ] message(sprintf("Installing latest bincraft release: %s", latest)) - -# uvr addresses Forgejo repos as `forgejo::host/owner/repo@ref` rather than as a -# git URL, so drop the scheme and the trailing `.git` from `repo_url`. -spec <- sprintf( - "forgejo::%s@%s", - sub("\\.git$", "", sub("^[a-z]+://", "", repo_url)), - latest -) - -# `local/uvr-install.sh` when run from the repo root, `/work/local/` in the -# build-one image, which copies the two scripts into a flatter layout. -helper <- Sys.getenv("UVR_INSTALL_SH", unset = "") -if (!nzchar(helper)) { - candidates <- c("local/uvr-install.sh", "/work/local/uvr-install.sh") - found <- candidates[file.exists(candidates)] - if (length(found) == 0L) { - stop("Could not locate uvr-install.sh; set UVR_INSTALL_SH", call. = FALSE) - } - helper <- found[1L] -} - -# Point uvr at the R running this script and at the library it would install -# into, so the per-R-minor passes in the build pipelines (which call a different -# Rscript with R_LIBS_USER pointed elsewhere) target their own R and library. -Sys.setenv( - UVR_R_BIN = file.path(R.home("bin"), "R"), - UVR_TARGET_LIB = .libPaths()[1L] -) - -status <- system2(helper, shQuote(spec)) -if (!identical(status, 0L)) { - stop( - sprintf("uvr failed to install %s (exit %s)", spec, status), - call. = FALSE - ) -} - +pak::pak(sprintf("git::%s@%s", repo_url, latest)) message(sprintf( "bincraft %s installed (%s)", as.character(utils::packageVersion("bincraft")), diff --git a/local/list-unsafe-flat-objects.R b/local/list-unsafe-flat-objects.R deleted file mode 100644 index 60649a2..0000000 --- a/local/list-unsafe-flat-objects.R +++ /dev/null @@ -1,161 +0,0 @@ -### Print the flat-slot objects recorded as unable to load under some R minor. -# -# Usage: Rscript local/list-unsafe-flat-objects.R -# -# Prints one ` ` per line, for a caller that rebuilds each -# under every installed R minor. -# -# The verdicts come from `abi_flat_safety`, written by the flat-safety backfill -# from the binaries themselves: an object is unsafe when it references a symbol -# some supported minor does not export. That is the authoritative list of what -# needs a per-minor build, and it replaces the ad-hoc symbol scans that produced -# earlier repair lists. - -options(error = function() { - cat("ERROR:", geterrmessage(), "\n", file = stdout()) - q(status = 1) -}) - -args <- commandArgs(trailingOnly = TRUE) -if (length(args) < 2L) { - stop("usage: list-unsafe-flat-objects.R ", call. = FALSE) -} -arch <- args[[1L]] -codename <- args[[2L]] - -con <- DBI::dbConnect( - RPostgres::Postgres(), - dbname = "build_metadata", - host = "r-binaries.devxy.io", - port = 15432L, - user = "rpkgs", - password = Sys.getenv("PGPASS"), - sslmode = "require" -) -on.exit(try(DBI::dbDisconnect(con), silent = TRUE), add = TRUE) - -rows <- DBI::dbGetQuery( - con, - paste0( - "SELECT package, version, unsupported FROM abi_flat_safety ", - "WHERE NOT safe AND codename = $1 AND arch = $2 ", - "ORDER BY package, version" - ), - params = list(codename, arch) -) - -# Drop what is already repaired. The verdict describes the *flat* object, and a -# per-minor build does not change it, so a resumed run would otherwise rebuild -# everything it did last time: ~2500 objects of duplicated compute after the -# first fleet run was interrupted. -# -# An object is repaired once a per-minor build exists for every minor that -# cannot load the flat one. The bucket is the source of truth for that, and it -# cannot be routed. -if (nrow(rows) > 0L) { - s3fs::s3_file_system( - aws_access_key_id = Sys.getenv("B2_S3_ACCESS_KEY"), - aws_secret_access_key = Sys.getenv("B2_S3_SECRET_KEY"), - endpoint = "https://s3.eu-central-003.backblazeb2.com", - region_name = "eu-central-003", - refresh = TRUE - ) - contrib <- sprintf( - "devxy-rpkgs-binaries/%s/%s/latest/src/contrib", - arch, - codename - ) - objects <- tryCatch( - s3fs::s3_dir_ls(contrib, recurse = TRUE), - error = function(e) character(0) - ) - if (length(objects) == 0L) { - stop( - sprintf( - "Could not list %s; refusing to guess what is already repaired.", - contrib - ), - call. = FALSE - ) - } - present <- sub(sprintf("^(s3://)?%s/", contrib), "", objects) - - # No recorded minors means unsafe without a reason, so require a build under - # every minor the image carries rather than assume one is enough. - installed <- sub( - "^([0-9]+[.][0-9]+).*$", - "\\1", - basename(list.dirs("/opt/R", recursive = FALSE)) - ) - installed <- unique(installed[grepl("^[0-9]+[.][0-9]+$", installed)]) - - repaired <- vapply( - seq_len(nrow(rows)), - function(i) { - u <- rows$unsupported[[i]] - minors <- if (is.na(u) || !nzchar(u)) { - installed - } else { - trimws(strsplit(u, ",", fixed = TRUE)[[1L]]) - } - if (length(minors) == 0L) { - return(FALSE) - } - wanted <- sprintf( - "%s/%s_%s.tar.gz", - minors, - rows$package[[i]], - rows$version[[i]] - ) - all(wanted %in% present) - }, - logical(1L) - ) - if (any(repaired)) { - message(sprintf( - "%s/%s: %d of %d already have per-minor builds for every minor that needs one; skipping those.", - arch, - codename, - sum(repaired), - nrow(rows) - )) - } - rows <- rows[!repaired, , drop = FALSE] -} - -# An empty result is ambiguous: either nothing is unsafe, or the backfill never -# ran for this slot. Say which, rather than let the caller read silence as done. -if (nrow(rows) == 0L) { - total <- DBI::dbGetQuery( - con, - "SELECT count(*) AS n FROM abi_flat_safety WHERE codename = $1 AND arch = $2", - params = list(codename, arch) - )$n[[1L]] - if (total == 0L) { - stop( - sprintf( - "No verdicts recorded for %s/%s at all. Run the flat-safety backfill first; an empty repair list here would look like success.", # nolint - arch, - codename - ), - call. = FALSE - ) - } - # `count(*)` arrives as a double (or integer64), and `%d` rejects both, so the - # nothing-to-do path used to die on its own success message. - message(sprintf( - "%s/%s: %s objects recorded, none unsafe. Nothing to repair.", - arch, - codename, - format(as.numeric(total), scientific = FALSE) - )) - quit(status = 0) -} - -message(sprintf( - "%s/%s: %d objects need a per-minor build", - arch, - codename, - nrow(rows) -)) -cat(sprintf("%s %s", rows$package, rows$version), sep = "\n") diff --git a/local/newer-r-helpers.R b/local/newer-r-helpers.R deleted file mode 100644 index 4b83204..0000000 --- a/local/newer-r-helpers.R +++ /dev/null @@ -1,75 +0,0 @@ -# Evaluate every R constraint, including patch-level and upper bounds. -r_requirement_satisfied <- function(depends, r_version) { - if (is.na(depends) || !nzchar(trimws(depends))) { - return(TRUE) - } - entries <- trimws(strsplit(depends, ",", fixed = TRUE)[[1L]]) - entries <- entries[grepl("^R(\\s|\\(|$)", entries)] - all(vapply( - entries, - function(entry) { - if (entry == "R") { - return(TRUE) - } - match <- regmatches( - entry, - regexec( - "^R\\s*\\(\\s*(>=|<=|==|>|<)\\s*([0-9]+(?:[.-][0-9]+)*)\\s*\\)$", - entry, - perl = TRUE - ) - )[[1L]] - if (length(match) != 3L) { - stop("Cannot parse R dependency: ", entry) - } - comparison <- utils::compareVersion(r_version, match[3L]) - switch( - match[2L], - ">=" = comparison >= 0L, - "<=" = comparison <= 0L, - "==" = comparison == 0L, - ">" = comparison > 0L, - "<" = comparison < 0L - ) - }, - logical(1L) - )) -} - -# Use unfiltered current CRAN metadata: available.packages() would hide exactly -# the packages that cannot run under the primary interpreter. -# Propagate R incompatibility through required dependencies, not Suggests. -newer_r_candidates <- function(db, primary_r, target_r) { - db <- as.data.frame(db, stringsAsFactors = FALSE) - # CRAN also lists R-specific Recommended copies (e.g. Matrix for R-devel). - # Prefer the ordinary contrib record, or a future-R copy can incorrectly - # block every package depending on Matrix on all released interpreters. - if ("Path" %in% names(db)) { - db <- db[order(!is.na(db$Path) & nzchar(db$Path)), , drop = FALSE] - } - db <- db[!duplicated(db$Package), , drop = FALSE] - deps <- tools::package_dependencies( - packages = db$Package, - db = as.matrix(db), - which = c("Depends", "Imports", "LinkingTo"), - recursive = FALSE - ) - supported <- function(version) { - ok <- vapply(db$Depends, r_requirement_satisfied, logical(1L), version) - repeat { - blocked <- db$Package[!ok] - next_ok <- ok & - !vapply(deps, function(x) any(x %in% blocked), logical(1L)) - if (identical(ok, next_ok)) { - return(ok) - } - ok <- next_ok - } - } - selected <- !supported(primary_r) & supported(target_r) - if ("OS_type" %in% names(db)) { - selected <- selected & (is.na(db$OS_type) | db$OS_type != "windows") - } - result <- db[selected, c("Package", "Version"), drop = FALSE] - result[order(result$Package), , drop = FALSE] -} diff --git a/local/packages-to-build.R b/local/packages-to-build.R index 31b667f..7a3652d 100644 --- a/local/packages-to-build.R +++ b/local/packages-to-build.R @@ -12,18 +12,18 @@ library(progressr, quietly = TRUE) suppressPackageStartupMessages(library(data.table)) # Sys.setenv("OS" = "alpine") -# Sys.setenv("OS_VERSION" = "3.23") +# Sys.setenv("OS_VERSION" = "3.22") # Sys.setenv("ARCH" = "arm64") -arch <- Sys.getenv("ARCH") -# target: alpine-323, ubuntu-2404, redhat-9, etc. -platform <- paste( +arch = Sys.getenv("ARCH") +# target: alpine-322, ubuntu-2404, redhat-9, etc. +platform = paste( Sys.getenv("OS"), gsub("[.]", "", Sys.getenv("OS_VERSION")), sep = "-" ) # Use bincraft's codename detection for S3 paths (e.g. "rhel10" not "redhat10") -codename <- bincraft::set_codename(NULL) +codename = bincraft::set_codename(NULL) con <- DBI::dbConnect( RPostgres::Postgres(), @@ -35,8 +35,8 @@ con <- DBI::dbConnect( sslmode = "require" ) -cran_archive <- tools::CRAN_archive_db() -cran_release <- tools::CRAN_package_db() +cran_archive = tools::CRAN_archive_db() +cran_release = tools::CRAN_package_db() # Subset cran_archive to only those packages cran_archive_in_release <- cran_archive[ names(cran_archive) %in% cran_release$Package @@ -68,36 +68,9 @@ archive_versions <- archive_versions[ ] # Now get release versions (assuming cran_release has Package and Version columns) -# -# Packages published in the last few days are held back. `check_for_binary()` -# reads the published version from the `cran` GitHub mirror -# (`GET /repos/cran//commits`), and that mirror lags CRAN: a package that -# has just appeared has no repository there yet. The call then 404s, which is -# permanent, but it is wrapped in `purrr::insistently` and retried ten times -# with a backoff capped at 60s - so one unmirrored package burns about five -# minutes and then aborts the whole shard. -# -# Holding them back costs nothing: the daily update pipeline builds new and -# updated packages anyway, and they arrive here on the next run once the mirror -# has caught up. -mirror_lag_days <- as.numeric( - Sys.getenv("CRAN_MIRROR_LAG_DAYS", unset = "3") -) -published <- as.POSIXct(cran_release$Published, tz = "UTC") -too_recent <- !is.na(published) & - published > (Sys.time() - mirror_lag_days * 86400) -if (any(too_recent)) { - message(sprintf( - "Holding back %d package(s) published in the last %g day(s); the cran GitHub mirror will not have them yet: %s", - sum(too_recent), - mirror_lag_days, - paste(utils::head(cran_release$Package[too_recent], 10L), collapse = ", ") - )) -} - release_versions <- data.table( - Package = cran_release$Package[!too_recent], - Version = as.character(cran_release$Version[!too_recent]) + Package = cran_release$Package, + Version = as.character(cran_release$Version) ) pkgs_to_build <- unique(rbind(archive_versions, release_versions, fill = TRUE)) @@ -111,205 +84,18 @@ s3fs::s3_file_system( region_name = "eu-central-003", refresh = TRUE ) -s3_pkgs <- s3fs::s3_dir_ls( +s3_pkgs = s3fs::s3_dir_ls( sprintf("devxy-rpkgs-binaries/%s/%s/latest/src/contrib", arch, codename), recurse = TRUE ) -# `recurse = TRUE` walks the per-minor slots as well, and `basename()` throws -# the directory away - so `4.5/curl_1.0.tar.gz` and `curl_1.0.tar.gz` collapse -# to one name and a package present under *any* R minor counts as built for -# *all* of them. The candidate list then prunes exactly the packages a -# per-minor pass exists to build: arm64/alpine324 reported "0 remaining" for -# both 4.4 and 4.6 while its indexes were dropping 2400+ packages as missing. -# -# Per-minor objects are therefore excluded here. Presence in a specific minor -# is decided downstream, where the running R version is known: build-all.R -# filters on it, and `build_binary_package()` checks the per-minor path per -# package and skips what is already there. -# -# Archive/ is kept. Those are versions that were built and then superseded; -# dropping them would make every archived version look unbuilt. -per_minor_object <- grepl("/[0-9]+\\.[0-9]+/[^/]+$", s3_pkgs) -if (any(per_minor_object)) { - cat(sprintf( - "Excluding %d per-minor object(s) from the presence check; those are decided per pass\n", - sum(per_minor_object) - )) -} -file_names <- basename(s3_pkgs[!per_minor_object]) +# Save the raw S3 file listing for the build step to use as s3_package_cache +# This avoids loading s3fs/reticulate in the build container, saving memory for pak forks +saveRDS(basename(s3_pkgs), "/mnt/cache/packages/s3_cache.rds") -# An object occupying a key is not proof a binary was built: a package whose -# build failed has its CRAN source published under exactly that name. Left in -# the cache, `build_binary_package()` reads it as "already built" and skips the -# package forever, which is how alpine324 accumulated ~13.5k source tarballs. -# -# bincraft stamps `Built` only on records it actually built, so the slot's own -# index distinguishes them. A slot last indexed by a bincraft that predates that -# fix stamps `Built` on everything, so the cache is then unchanged from before. -# Archived objects have no index record and are kept: unknown means binary, -# never "rebuild it". -index_url <- sprintf( - "https://cran.rpkgs.com/%s/%s/latest/src/contrib/PACKAGES.gz", - arch, - codename -) -read_source_served <- function(index) { - tryCatch( - { - con_idx <- gzcon(url(index, open = "rb")) - on.exit(close(con_idx), add = TRUE) - idx <- read.dcf(con_idx, fields = c("Package", "Version", "Built")) - sprintf( - "%s_%s.tar.gz", - idx[is.na(idx[, "Built"]), "Package"], - idx[is.na(idx[, "Built"]), "Version"] - ) - }, - error = function(e) { - cat(sprintf( - "WARNING: could not read %s (%s); keeping that index's cache in full\n", - index, - conditionMessage(e) - )) - character(0) - } - ) -} - -source_served <- read_source_served(index_url) - -binary_cache <- setdiff(file_names, source_served) - -# The existence cache and the candidate list need different views of the same -# listing, and conflating them is what made this wrong in both directions. -# -# The candidate list must ignore per-minor objects, or a package present under -# one minor prunes itself from every other minor's work (#189). The existence -# cache must NOT ignore them, or `build_binary_package()` is told nothing is -# present under any minor and recompiles the lot: amd64/resolute recompiled -# 8683 packages it had already built, reporting "already exists in S3" three -# times. -# -# So the cache keeps the path relative to the slot, and `build-all.R` selects -# the part that matches the pass it is running: the flat slot for the primary, -# `/` for a per-minor pass. -# -# `s3_dir_ls()` returns keys with the `s3://` scheme attached, so stripping the -# prefix as a fixed substring takes it out of the middle and leaves -# `s3://4.4/curl_1.0.tar.gz`. That leading scheme defeats the `^/` test -# downstream, so every per-minor object is read as a flat-slot object: the -# sensitive passes see an empty cache and recompile everything they already -# have. Anchor the pattern and swallow the scheme with it. -contrib_prefix <- sprintf( - "^(s3://)?devxy-rpkgs-binaries/%s/%s/latest/src/contrib/", - arch, - codename -) -relative_paths <- sub(contrib_prefix, "", s3_pkgs) - -# The strip is load-bearing and fails silently, so assert it. Both counts are -# derived from the same listing and use the same shape of pattern, so they must -# agree exactly; a mismatch means the prefix no longer describes the keys. -stripped_per_minor <- grepl("^[0-9]+\\.[0-9]+/[^/]+$", relative_paths) -if (sum(stripped_per_minor) != sum(per_minor_object)) { - stop(sprintf( - paste0( - "S3 prefix strip failed: %d per-minor objects in the listing, %d after ", - "stripping /%s/. Example key: %s" - ), - sum(per_minor_object), - sum(stripped_per_minor), - contrib_prefix, - if (any(per_minor_object)) { - s3_pkgs[which(per_minor_object)[1L]] - } else { - "" - } - )) -} -source_basenames <- source_served - -# A source-fallback list describes ONE index, so it may only be applied to that -# index's objects. Applied to every path it deletes real per-minor binaries from -# the cache: a package the flat slot serves as CRAN source still has a genuine -# `4.4/.tar.gz`, and dropping that name makes every per-minor pass rebuild -# it, every run, forever. -# -# The overlap is total rather than partial, which is why this pinned the skip -# rate near 0%: the sensitive candidate list is exactly "sensitive packages with -# no flat binary", which is the same set this was removing. amd64/resolute -# listed 21212 per-minor objects but cached only 13572, then recompiled osmdata, -# osqp and outbreaker2 while their 4.4 binaries sat in the bucket. -# -# So judge each minor by its own index, and the flat slot by the flat index. -is_per_minor_path <- grepl("^[0-9]+\\.[0-9]+/", relative_paths) -path_minor <- ifelse( - is_per_minor_path, - sub("^([0-9]+\\.[0-9]+)/.*$", "\\1", relative_paths), - "" -) -minors_present <- sort(unique(path_minor[is_per_minor_path])) -per_minor_source <- lapply(minors_present, function(m) { - read_source_served(sprintf( - "https://cran.rpkgs.com/%s/%s/latest/src/contrib/%s/PACKAGES.gz", - arch, - codename, - m - )) -}) -names(per_minor_source) <- minors_present -cat(sprintf( - "Source fallbacks per index: flat=%d%s\n", - length(source_basenames), - if (length(minors_present)) { - paste0( - ", ", - paste( - sprintf("%s=%d", minors_present, lengths(per_minor_source)), - collapse = ", " - ) - ) - } else { - "" - } -)) - -served_as_source <- vapply( - seq_along(relative_paths), - function(i) { - scope <- if (nzchar(path_minor[i])) { - per_minor_source[[path_minor[i]]] - } else { - source_basenames - } - basename(relative_paths[i]) %in% scope - }, - logical(1L) -) -existence_cache <- relative_paths[!served_as_source] -cat(sprintf( - "S3 cache: %d objects, %d served as CRAN source, %d usable binaries\n", - length(file_names), - length(file_names) - length(binary_cache), - length(binary_cache) -)) - -# Save the S3 file listing for the build step to use as s3_package_cache. -# This avoids loading s3fs/reticulate in the build container, saving memory for -# the dependency-installer subprocesses -# Mark the format explicitly. `build-all.R` has to tell a slot-relative cache -# from a pre-#191 basename one, and sniffing for a "/" cannot: a new-format -# cache for a slot with no per-minor or Archive objects holds bare names too, -# and would be read as legacy and used unfiltered, which makes a per-minor pass -# believe the flat slot's binaries are its own and build nothing. -attr(existence_cache, "slot_relative") <- TRUE -saveRDS(existence_cache, "/mnt/cache/packages/s3_cache.rds") -# Built from the filtered listing, not the raw one: `s3_dt` is subtracted from -# the build list below, so a source fallback left in here would exclude the very -# package that needs building. -matches <- regexec("^([A-Za-z0-9.]+)_([0-9][^/]*)\\.tar\\.gz$", binary_cache) -parts <- regmatches(binary_cache, matches) +file_names <- basename(s3_pkgs) +matches <- regexec("^([A-Za-z0-9.]+)_([0-9][^/]*)\\.tar\\.gz$", file_names) +parts <- regmatches(file_names, matches) parts <- parts[sapply(parts, length) == 3] s3_dt <- data.table( Package = sapply(parts, `[`, 2), @@ -318,22 +104,11 @@ s3_dt <- data.table( ### Get all packages with build errors -# Scoped to the R minor this snapshot is computed under. A failure is a fact -# about one interpreter: without the scope a package that failed under the -# primary minor is dropped from the candidate list for every other minor too, -# which is the same omission fixed in local/build-all.R and in bincraft's -# check_package_error(). -snapshot_r_minor <- paste( - R.version$major, - strsplit(R.version$minor, ".", fixed = TRUE)[[1L]][1L], - sep = "." -) sql_query <- paste0( # nolint "SELECT error_occurred FROM ", "single_builds", - " WHERE name = $1 AND tag = $2 AND platform = $3 AND arch = $4", - " AND substring(r_version from '^[0-9]+[.][0-9]+') = $5" + " WHERE name = $1 AND tag = $2 AND platform = $3 AND arch = $4" ) # Function to query for a single package-version query_error <- function(pkg, ver) { @@ -341,7 +116,7 @@ query_error <- function(pkg, ver) { ~ DBI::dbGetQuery( con, sql_query, - params = list(pkg, ver, platform, arch, snapshot_r_minor) + params = list(pkg, ver, platform, arch) ), rate = purrr::rate_backoff( pause_base = 1L, @@ -355,30 +130,12 @@ query_error <- function(pkg, ver) { } # Fetch all relevant columns from the database -# Scoped to the snapshot's own R minor, like `check_package_error()` in -# bincraft (#119) and the per-minor error query (#189). Unscoped, a single -# failure under any one minor removed the package from the candidate list for -# every minor, permanently: the row is keyed by (name, tag, platform, arch) -# only, so an error under 4.6 also cancelled its 4.4 and 4.5 builds. -# -# That is what the residual per-minor gaps were. On amd64/alpine324, 505 of the -# 508 ABI-risky packages missing from 4.4 were also missing from 4.6 -- a 99% -# overlap, which per-minor build failures would not produce. None of them were -# even attempted by the run that had just succeeded. -# -# Errors specific to another minor are still respected downstream: -# `filter_packages_with_errors()` runs per pass with that pass's r_version. errored_pkgs <- DBI::dbGetQuery( con, sprintf( - paste0( - "SELECT name, tag FROM single_builds WHERE error_occurred = TRUE ", - "and platform='%s' and arch='%s' ", - "and substring(r_version from '^[0-9]+[.][0-9]+') = '%s'" - ), + "SELECT name, tag FROM single_builds WHERE error_occurred = TRUE and platform='%s' and arch='%s'", platform, - arch, - snapshot_r_minor + arch ) ) errored_pkgs <- as.data.table(errored_pkgs) diff --git a/local/patches/README.md b/local/patches/README.md index ce60c9a..e875217 100644 --- a/local/patches/README.md +++ b/local/patches/README.md @@ -81,40 +81,20 @@ Run the trial build inside the failing platform's build-env image; it uploads/ar Rscript local/trial-build-patch.R ``` -#### Autonomous PR + trial-build gate - -`--open-pr` closes the loop: it writes the top-N candidates (by failure volume) onto the reused `auto/registry-patch-proposals` branch and opens/updates a single PR. -`.crow/auto-apply-patches.yaml` runs this on a cron. -`FORGEJO_TOKEN` is used for both the branch push and the PR (no separate write-scoped secret). - -```bash -# Bounded batch; opens/updates one PR. -PGPASS=... FORGEJO_TOKEN=... Rscript local/propose-patches.R --open-pr --limit 10 -``` - -The merge gate is `.crow/trial-build-registry.yaml`: matrixed over the build-env images, each platform trial-builds only the entries the branch **adds** (`local/trial-build-registry.R`, which diffs the registry against `main`) and is green only if every new entry builds. -The repo uses no `pull_request` triggers, so this gate runs manually or on a cron against the auto-patch branch (`--var patch_branch=...`); wiring it to fire on the PR needs `event: pull_request` enabled on the forge. - `--write` and `--open-issue` also append to `local/patches/proposals-log.json`, a ledger of what was proposed. ### Feedback loop (step 4) `local/proposal-tracking.R` reports the signature hit rate, proposed-vs-merged status (a proposal counts as merged once its package appears in the registry), and retirement candidates (registry entries whose package no longer appears in any current failure, so the upstream cause was likely fixed). -It also surfaces the classifier's **blind spots**: the unclassified failures (candidates for a new signature) and the groups blocked on a dependency build, so the unknown buckets get the same visibility as the proposals. -It is read-only on the DB, with an optional Forgejo issue as the only write. +It is read-only. ```bash -# Print the metrics + blind spots. PGPASS=... Rscript local/proposal-tracking.R --json metrics.json -# Post/update a "needs signatures" tracking issue with the unclassified failures. -PGPASS=... FORGEJO_TOKEN=... Rscript local/proposal-tracking.R --open-issue ``` -The unclassified groups are the natural place to discover which new signatures are worth adding to `build_signatures()`. The pure metric/ledger helpers live in `local/proposal-tracking-lib.R` and are covered by `local/tests/test-proposal-tracking-lib.R`. ### Scheduled run -`.crow/weekly-patch-proposals.yaml` runs both steps weekly (register the `weekly-patch-proposals` cron in the crow UI). -It posts/updates two Forgejo issues -- one with the auto-proposable entries, one with the unclassified/blocked failures -- and logs the feedback-loop metrics. -It clones read-only; the only writes are the two tracking issues. +`.crow/weekly-patch-proposals.yaml` runs both steps weekly (register the `weekly-patch-proposals` cron in the crow UI): it posts/updates a Forgejo tracking issue with the auto-proposable entries and logs the feedback-loop metrics. +It clones read-only; the only write is the tracking issue. diff --git a/local/patches/RcppParallel/disable-tbb.patch b/local/patches/RcppParallel/disable-tbb.patch new file mode 100644 index 0000000..0fe8ad9 --- /dev/null +++ b/local/patches/RcppParallel/disable-tbb.patch @@ -0,0 +1,16 @@ +diff --git a/src/Makevars.in b/src/Makevars.in +index be8445f..faee771 100644 +--- a/src/Makevars.in ++++ b/src/Makevars.in +@@ -60,7 +60,10 @@ else + endif + + ifeq ($(UNAME), Linux) +- USE_TBB=Linux ++ # bincraft patch: the bundled Intel TBB build hangs/fails on musl (Alpine) ++ # and newer toolchains (g++ 15). Skip it (leave USE_TBB unset) and force the ++ # TinyThread backend so RcppParallel still builds. ++ PKG_CXXFLAGS += -DRCPP_PARALLEL_USE_TBB=0 + endif + + ifeq ($(UNAME), SunOS) diff --git a/local/patches/RcppParallel/force-bundled-tbb-6.0.0.patch b/local/patches/RcppParallel/force-bundled-tbb-6.0.0.patch deleted file mode 100644 index a7f91dc..0000000 --- a/local/patches/RcppParallel/force-bundled-tbb-6.0.0.patch +++ /dev/null @@ -1,152 +0,0 @@ -diff --git a/R/aaa.R b/R/aaa.R -index 568a2aa..2744059 100644 ---- a/R/aaa.R -+++ b/R/aaa.R -@@ -5,4 +5,21 @@ TBB_LIB <- "" - TBB_INC <- "" - - TBB_NAME <- "tbb" --TBB_MALLOC_NAME <- "tbbmalloc" -\ No newline at end of file -+TBB_MALLOC_NAME <- "tbbmalloc" -+ -+# bincraft patch: build images (ours did until build-env-images c51ed09) and -+# plenty of user environments export TBB_ROOT / TBB_LIB / TBB_INC. A binary we -+# publish always carries its own oneTBB in RcppParallel/lib -- see the -+# companion changes in tools/config/configure.R and src/install.libs.R -- so -+# honouring those variables at run time is actively harmful: .onLoad() would -+# dyn.load() a second, unrelated TBB into the process next to the bundled one -+# (two copies of the same symbols in the global scope, which segfaults R on -+# load), and RcppParallelLibs() / CxxFlags() would hand that system TBB to -+# dependents such as rstan, putting NEEDED libtbb.so.12 back into their -+# binaries. Read the variables only when explicitly opted back in. -+bincraftGetenv <- function(name, unset = "") { -+ if (Sys.getenv("BINCRAFT_ALLOW_SYSTEM_TBB", unset = "FALSE") == "TRUE") -+ Sys.getenv(name, unset = unset) -+ else -+ unset -+} -\ No newline at end of file -diff --git a/R/tbb.R b/R/tbb.R -index 3d8fbb3..9560165 100644 ---- a/R/tbb.R -+++ b/R/tbb.R -@@ -17,7 +17,7 @@ tbbLibraryPath <- function(name = NULL) { - sysname <- Sys.info()[["sysname"]] - - # find root for TBB install -- tbbRoot <- Sys.getenv("TBB_LIB", unset = tbbRoot()) -+ tbbRoot <- bincraftGetenv("TBB_LIB", unset = tbbRoot()) - if (is.null(name)) - return(tbbRoot) - -@@ -60,7 +60,7 @@ tbbCxxFlags <- function() { - flags <- c("-DRCPP_PARALLEL_USE_TBB=1") - - # if TBB_INC is set, apply those library paths -- tbbInc <- Sys.getenv("TBB_INC", unset = TBB_INC) -+ tbbInc <- bincraftGetenv("TBB_INC", unset = TBB_INC) - if (!file.exists(tbbInc)) { - tbbInc <- system.file("include", package = "RcppParallel") - } -@@ -100,7 +100,7 @@ tbbLdFlags <- function() { - } - - # shortcut if TBB_LIB defined -- tbbLib <- Sys.getenv("TBB_LINK_LIB", Sys.getenv("TBB_LIB", unset = TBB_LIB)) -+ tbbLib <- bincraftGetenv("TBB_LINK_LIB", bincraftGetenv("TBB_LIB", unset = TBB_LIB)) - if (nzchar(tbbLib)) { - if (R.version$os == "emscripten") { - fmt <- "-L%1$s -l%2$s" -diff --git a/src/install.libs.R b/src/install.libs.R -index 4dcbac2..57dbc74 100644 ---- a/src/install.libs.R -+++ b/src/install.libs.R -@@ -258,6 +258,18 @@ prependFlags <- function(prependFlags, toFlags) { - tbbLib <- Sys.getenv("TBB_LIB") - tbbInc <- Sys.getenv("TBB_INC") - -+# bincraft patch: the companion change in tools/config/configure.R stops an -+# ambient TBB_LIB / TBB_INC from selecting a system TBB, but this script is -+# also run directly by `R CMD INSTALL` (not only through the `tbb` rule in -+# src/Makevars, which passes the configured values), so at install time it -+# still sees the image's environment and would symlink the system libraries -+# into RcppParallel/lib. Drop them here for the same reason, under the same -+# opt-out. -+if (Sys.getenv("BINCRAFT_ALLOW_SYSTEM_TBB", unset = "FALSE") != "TRUE") { -+ tbbLib <- "" -+ tbbInc <- "" -+} -+ - args <- commandArgs(trailingOnly = TRUE) - if (identical(args, "build")) { - if (nzchar(tbbLib) && nzchar(tbbInc)) { -diff --git a/tools/config/configure.R b/tools/config/configure.R -index a001e63..bfe8c8e 100644 ---- a/tools/config/configure.R -+++ b/tools/config/configure.R -@@ -144,6 +144,24 @@ tbbRoot <- Sys.getenv("TBB_ROOT", unset = NA) - tbbLib <- Sys.getenv("TBB_LIB", unset = NA) - tbbInc <- Sys.getenv("TBB_INC", unset = NA) - -+# bincraft patch: ignore an ambient TBB_ROOT / TBB_LIB / TBB_INC. Our build -+# images exported these until build-env-images c51ed09 (a leftover from -+# RcppParallel 5.x, whose bundled Intel TBB would not build on musl or with -+# modern g++), and any of them switches the branches below to a system TBB. -+# The published binary then records NEEDED libtbb.so.12 (or libtbb.so.2 for -+# the classic Intel TBB) and gets a RcppParallel/lib full of absolute symlinks -+# into the image's library dir, so it cannot dyn.load on a consumer machine -+# without that exact TBB. 6.x bundles oneTBB 2022 and builds it with cmake on -+# every platform we ship, so the bundled copy is always the right choice here; -+# forcing it in the package rather than relying on the image environment keeps -+# the binary correct whichever image version CI happens to pull. Set -+# BINCRAFT_ALLOW_SYSTEM_TBB=TRUE to restore the upstream behaviour. -+if (Sys.getenv("BINCRAFT_ALLOW_SYSTEM_TBB", unset = "FALSE") != "TRUE") { -+ tbbRoot <- NA -+ tbbLib <- NA -+ tbbInc <- NA -+} -+ - tbbName <- Sys.getenv("TBB_NAME", unset = "tbb") - tbbMallocName <- Sys.getenv("TBB_MALLOC_NAME", unset = "tbbmalloc") - -@@ -224,10 +242,21 @@ define( - ) - - # set PKG_LIBS -+# -+# bincraft patch: the library directories below are passed as plain '-L', not -+# '-Wl,-L'. gcc expands its own search dirs (/usr/lib64, /usr/lib/) -+# into explicit '-L' options ahead of anything forwarded verbatim with '-Wl,', -+# so with '-Wl,-L' a system libtbb.so wins over the one named here: on a build -+# host with a distro TBB installed, '-ltbb' resolves to that library and -+# RcppParallel.so records its SONAME (libtbb.so.12, or libtbb.so.2 for the -+# classic Intel TBB) instead of the bundled 'libtbb.so'. The binary then loads -+# the system TBB rather than the copy shipped in RcppParallel/lib, and fails -+# outright on a machine that has no system TBB. gcc places a plain '-L' before -+# its built-in dirs, so the intended library is found first. - pkgLibs <- if (!is.na(tbbLib)) { - - c( -- "-Wl,-L\"$(TBB_LIB)\"", -+ "-L\"$(TBB_LIB)\"", - sprintf("-Wl,-rpath,%s", shQuote(tbbLib)), - "-l$(TBB_NAME)", - "-l$(TBB_MALLOC_NAME)" -@@ -240,14 +269,14 @@ pkgLibs <- if (!is.na(tbbLib)) { - } else if (R.version$os == "emscripten") { - - c( -- "-Wl,-Ltbb/build/lib_release", -+ "-Ltbb/build/lib_release", - "-l$(TBB_NAME)" - ) - - } else { - - c( -- "-Wl,-Ltbb/build/lib_release", -+ "-Ltbb/build/lib_release", - "-l$(TBB_NAME)", - "-l$(TBB_MALLOC_NAME)" - ) diff --git a/local/patches/RcppParallel/force-bundled-tbb-6.1.x.patch b/local/patches/RcppParallel/force-bundled-tbb-6.1.x.patch deleted file mode 100644 index 948643c..0000000 --- a/local/patches/RcppParallel/force-bundled-tbb-6.1.x.patch +++ /dev/null @@ -1,157 +0,0 @@ -diff --git a/R/aaa.R b/R/aaa.R -index 568a2aa..2744059 100644 ---- a/R/aaa.R -+++ b/R/aaa.R -@@ -5,4 +5,21 @@ TBB_LIB <- "" - TBB_INC <- "" - - TBB_NAME <- "tbb" --TBB_MALLOC_NAME <- "tbbmalloc" -\ No newline at end of file -+TBB_MALLOC_NAME <- "tbbmalloc" -+ -+# bincraft patch: build images (ours did until build-env-images c51ed09) and -+# plenty of user environments export TBB_ROOT / TBB_LIB / TBB_INC. A binary we -+# publish always carries its own oneTBB in RcppParallel/lib -- see the -+# companion changes in tools/config/configure.R and src/install.libs.R -- so -+# honouring those variables at run time is actively harmful: .onLoad() would -+# dyn.load() a second, unrelated TBB into the process next to the bundled one -+# (two copies of the same symbols in the global scope, which segfaults R on -+# load), and RcppParallelLibs() / CxxFlags() would hand that system TBB to -+# dependents such as rstan, putting NEEDED libtbb.so.12 back into their -+# binaries. Read the variables only when explicitly opted back in. -+bincraftGetenv <- function(name, unset = "") { -+ if (Sys.getenv("BINCRAFT_ALLOW_SYSTEM_TBB", unset = "FALSE") == "TRUE") -+ Sys.getenv(name, unset = unset) -+ else -+ unset -+} -\ No newline at end of file -diff --git a/R/tbb.R b/R/tbb.R -index 152fd16..4b06aa5 100644 ---- a/R/tbb.R -+++ b/R/tbb.R -@@ -17,7 +17,7 @@ tbbLibraryPath <- function(name = NULL) { - sysname <- Sys.info()[["sysname"]] - - # find root for TBB install -- tbbRoot <- Sys.getenv("TBB_LIB", unset = tbbRoot()) -+ tbbRoot <- bincraftGetenv("TBB_LIB", unset = tbbRoot()) - if (is.null(name)) - return(tbbRoot) - -@@ -58,7 +58,7 @@ tbbCxxFlags <- function() { - flags <- c("-DRCPP_PARALLEL_USE_TBB=1") - - # if TBB_INC is set, apply those library paths -- tbbInc <- Sys.getenv("TBB_INC", unset = TBB_INC) -+ tbbInc <- bincraftGetenv("TBB_INC", unset = TBB_INC) - if (!file.exists(tbbInc)) { - tbbInc <- system.file("include", package = "RcppParallel") - } -@@ -96,7 +96,7 @@ tbbLdFlags <- function() { - } - - # shortcut if TBB_LIB defined -- tbbLib <- Sys.getenv("TBB_LINK_LIB", Sys.getenv("TBB_LIB", unset = TBB_LIB)) -+ tbbLib <- bincraftGetenv("TBB_LINK_LIB", bincraftGetenv("TBB_LIB", unset = TBB_LIB)) - if (nzchar(tbbLib)) { - if (R.version$os == "emscripten") { - fmt <- "-L%1$s -l%2$s" -diff --git a/src/install.libs.R b/src/install.libs.R -index e770838..1dd2fc7 100644 ---- a/src/install.libs.R -+++ b/src/install.libs.R -@@ -492,6 +492,18 @@ prependFlags <- function(prependFlags, toFlags) { - tbbLib <- Sys.getenv("TBB_LIB") - tbbInc <- Sys.getenv("TBB_INC") - -+# bincraft patch: the companion change in tools/config/configure.R stops an -+# ambient TBB_LIB / TBB_INC from selecting a system TBB, but this script is -+# also run directly by `R CMD INSTALL` (not only through the `tbb` rule in -+# src/Makevars, which passes the configured values), so at install time it -+# still sees the image's environment and would symlink the system libraries -+# into RcppParallel/lib. Drop them here for the same reason, under the same -+# opt-out. -+if (Sys.getenv("BINCRAFT_ALLOW_SYSTEM_TBB", unset = "FALSE") != "TRUE") { -+ tbbLib <- "" -+ tbbInc <- "" -+} -+ - args <- commandArgs(trailingOnly = TRUE) - if (identical(args, "build")) { - if (nzchar(tbbLib) && nzchar(tbbInc)) { -diff --git a/tools/config/configure.R b/tools/config/configure.R -index c3842f9..adf7876 100644 ---- a/tools/config/configure.R -+++ b/tools/config/configure.R -@@ -66,6 +66,24 @@ tbbRoot <- Sys.getenv("TBB_ROOT", unset = NA) - tbbLib <- Sys.getenv("TBB_LIB", unset = NA) - tbbInc <- Sys.getenv("TBB_INC", unset = NA) - -+# bincraft patch: ignore an ambient TBB_ROOT / TBB_LIB / TBB_INC. Our build -+# images exported these until build-env-images c51ed09 (a leftover from -+# RcppParallel 5.x, whose bundled Intel TBB would not build on musl or with -+# modern g++), and any of them switches the branches below to a system TBB. -+# The published binary then records NEEDED libtbb.so.12 (or libtbb.so.2 for -+# the classic Intel TBB) and gets a RcppParallel/lib full of absolute symlinks -+# into the image's library dir, so it cannot dyn.load on a consumer machine -+# without that exact TBB. 6.x bundles oneTBB 2022 and builds it with cmake on -+# every platform we ship, so the bundled copy is always the right choice here; -+# forcing it in the package rather than relying on the image environment keeps -+# the binary correct whichever image version CI happens to pull. Set -+# BINCRAFT_ALLOW_SYSTEM_TBB=TRUE to restore the upstream behaviour. -+if (Sys.getenv("BINCRAFT_ALLOW_SYSTEM_TBB", unset = "FALSE") != "TRUE") { -+ tbbRoot <- NA -+ tbbLib <- NA -+ tbbInc <- NA -+} -+ - tbbName <- Sys.getenv("TBB_NAME", unset = "tbb") - tbbMallocName <- Sys.getenv("TBB_MALLOC_NAME", unset = "tbbmalloc") - -@@ -146,6 +164,18 @@ define( - ) - - # set PKG_LIBS -+# -+# bincraft patch: the non-Windows library directories below are passed as -+# plain '-L', not '-Wl,-L'. gcc expands its own search dirs (/usr/lib64, -+# /usr/lib/) into explicit '-L' options ahead of anything forwarded -+# verbatim with '-Wl,', so with '-Wl,-L' a system libtbb.so wins over the one -+# named here: on a build host with a distro TBB installed, '-ltbb' resolves to -+# that library and RcppParallel.so records its SONAME (libtbb.so.12, or -+# libtbb.so.2 for the classic Intel TBB) instead of the bundled 'libtbb.so'. -+# The binary then loads the system TBB rather than the copy shipped in -+# RcppParallel/lib, and fails outright on a machine that has no system TBB. -+# gcc places a plain '-L' before its built-in dirs, so the intended library is -+# found first. - pkgLibs <- if (.Platform$OS.type == "windows") { - - if (!is.na(tbbLib) && file.exists(file.path(tbbInc, "oneapi"))) { -@@ -183,7 +213,7 @@ pkgLibs <- if (.Platform$OS.type == "windows") { - } else if (!is.na(tbbLib)) { - - c( -- "-Wl,-L\"$(TBB_LIB)\"", -+ "-L\"$(TBB_LIB)\"", - sprintf("-Wl,-rpath,%s", shQuote(tbbLib)), - "-l$(TBB_NAME)", - "-l$(TBB_MALLOC_NAME)" -@@ -192,14 +222,14 @@ pkgLibs <- if (.Platform$OS.type == "windows") { - } else if (R.version$os == "emscripten") { - - c( -- "-Wl,-Ltbb/build/lib_release", -+ "-Ltbb/build/lib_release", - "-l$(TBB_NAME)" - ) - - } else { - - c( -- "-Wl,-Ltbb/build/lib_release", -+ "-Ltbb/build/lib_release", - "-l$(TBB_NAME)", - "-l$(TBB_MALLOC_NAME)" - ) diff --git a/local/patches/RcppParallel/force-bundled-tbb-6.2.x.patch b/local/patches/RcppParallel/force-bundled-tbb-6.2.x.patch deleted file mode 100644 index d88cfc5..0000000 --- a/local/patches/RcppParallel/force-bundled-tbb-6.2.x.patch +++ /dev/null @@ -1,142 +0,0 @@ -diff --git a/R/aaa.R b/R/aaa.R -index 568a2aa..2744059 100644 ---- a/R/aaa.R -+++ b/R/aaa.R -@@ -5,4 +5,21 @@ TBB_LIB <- "" - TBB_INC <- "" - - TBB_NAME <- "tbb" --TBB_MALLOC_NAME <- "tbbmalloc" -\ No newline at end of file -+TBB_MALLOC_NAME <- "tbbmalloc" -+ -+# bincraft patch: build images (ours did until build-env-images c51ed09) and -+# plenty of user environments export TBB_ROOT / TBB_LIB / TBB_INC. A binary we -+# publish always carries its own oneTBB in RcppParallel/lib -- see the -+# companion changes in tools/config/configure.R and src/install.libs.R -- so -+# honouring those variables at run time is actively harmful: .onLoad() would -+# dyn.load() a second, unrelated TBB into the process next to the bundled one -+# (two copies of the same symbols in the global scope, which segfaults R on -+# load), and RcppParallelLibs() / CxxFlags() would hand that system TBB to -+# dependents such as rstan, putting NEEDED libtbb.so.12 back into their -+# binaries. Read the variables only when explicitly opted back in. -+bincraftGetenv <- function(name, unset = "") { -+ if (Sys.getenv("BINCRAFT_ALLOW_SYSTEM_TBB", unset = "FALSE") == "TRUE") -+ Sys.getenv(name, unset = unset) -+ else -+ unset -+} -\ No newline at end of file -diff --git a/R/tbb.R b/R/tbb.R -index b071833..e3bf7a5 100644 ---- a/R/tbb.R -+++ b/R/tbb.R -@@ -19,3 +19,3 @@ tbbLibraryPath <- function(name = NULL) { - # find root for TBB install -- tbbRoot <- Sys.getenv("TBB_LIB", unset = tbbRoot()) -+ tbbRoot <- bincraftGetenv("TBB_LIB", unset = tbbRoot()) - if (is.null(name)) -@@ -63,3 +63,3 @@ tbbCxxFlags <- function() { - # if TBB_INC is set, apply those library paths -- tbbInc <- Sys.getenv("TBB_INC", unset = TBB_INC) -+ tbbInc <- bincraftGetenv("TBB_INC", unset = TBB_INC) - if (!file.exists(tbbInc)) { -@@ -122,3 +122,3 @@ tbbLdFlags <- function() { - # shortcut if TBB_LIB defined -- tbbLib <- Sys.getenv("TBB_LINK_LIB", Sys.getenv("TBB_LIB", unset = TBB_LIB)) -+ tbbLib <- bincraftGetenv("TBB_LINK_LIB", bincraftGetenv("TBB_LIB", unset = TBB_LIB)) - if (nzchar(tbbLib)) { -diff --git a/src/install.libs.R b/src/install.libs.R -index 3b3cfda..c0e6f3e 100644 ---- a/src/install.libs.R -+++ b/src/install.libs.R -@@ -477,6 +477,18 @@ prependFlags <- function(prependFlags, toFlags) { - tbbLib <- Sys.getenv("TBB_LIB") - tbbInc <- Sys.getenv("TBB_INC") - -+# bincraft patch: the companion change in tools/config/configure.R stops an -+# ambient TBB_LIB / TBB_INC from selecting a system TBB, but this script is -+# also run directly by `R CMD INSTALL` (not only through the `tbb` rule in -+# src/Makevars, which passes the configured values), so at install time it -+# still sees the image's environment and would symlink the system libraries -+# into RcppParallel/lib. Drop them here for the same reason, under the same -+# opt-out. -+if (Sys.getenv("BINCRAFT_ALLOW_SYSTEM_TBB", unset = "FALSE") != "TRUE") { -+ tbbLib <- "" -+ tbbInc <- "" -+} -+ - args <- commandArgs(trailingOnly = TRUE) - if (identical(args, "build")) { - if (nzchar(tbbLib) && nzchar(tbbInc)) { -diff --git a/tools/config/configure.R b/tools/config/configure.R -index c203a1f..0050b5c 100644 ---- a/tools/config/configure.R -+++ b/tools/config/configure.R -@@ -79,6 +79,24 @@ tbbRoot <- Sys.getenv("TBB_ROOT", unset = NA) - tbbLib <- Sys.getenv("TBB_LIB", unset = NA) - tbbInc <- Sys.getenv("TBB_INC", unset = NA) - -+# bincraft patch: ignore an ambient TBB_ROOT / TBB_LIB / TBB_INC. Our build -+# images exported these until build-env-images c51ed09 (a leftover from -+# RcppParallel 5.x, whose bundled Intel TBB would not build on musl or with -+# modern g++), and any of them switches the branches below to a system TBB. -+# The published binary then records NEEDED libtbb.so.12 (or libtbb.so.2 for -+# the classic Intel TBB) and gets a RcppParallel/lib full of absolute symlinks -+# into the image's library dir, so it cannot dyn.load on a consumer machine -+# without that exact TBB. 6.x bundles oneTBB 2022 and builds it with cmake on -+# every platform we ship, so the bundled copy is always the right choice here; -+# forcing it in the package rather than relying on the image environment keeps -+# the binary correct whichever image version CI happens to pull. Set -+# BINCRAFT_ALLOW_SYSTEM_TBB=TRUE to restore the upstream behaviour. -+if (Sys.getenv("BINCRAFT_ALLOW_SYSTEM_TBB", unset = "FALSE") != "TRUE") { -+ tbbRoot <- NA -+ tbbLib <- NA -+ tbbInc <- NA -+} -+ - tbbName <- Sys.getenv("TBB_NAME", unset = "tbb") - tbbMallocName <- Sys.getenv("TBB_MALLOC_NAME", unset = "tbbmalloc") - -@@ -225,12 +243,23 @@ define( - ) - - # set PKG_LIBS -+# -+# bincraft patch: the library directories below are passed as plain '-L', not -+# '-Wl,-L'. gcc expands its own search dirs (/usr/lib64, /usr/lib/) -+# into explicit '-L' options ahead of anything forwarded verbatim with '-Wl,', -+# so with '-Wl,-L' a system libtbb.so wins over the one named here: on a build -+# host with a distro TBB installed, '-ltbb' resolves to that library and -+# RcppParallel.so records its SONAME (libtbb.so.12, or libtbb.so.2 for the -+# classic Intel TBB) instead of the bundled 'libtbb.so'. The binary then loads -+# the system TBB rather than the copy shipped in RcppParallel/lib, and fails -+# outright on a machine that has no system TBB. gcc places a plain '-L' before -+# its built-in dirs, so the intended library is found first. - pkgLibs <- if (!is.na(tbbLib)) { - - # a TBB supplied via TBB_LIB / TBB_ROOT. an rpath is meaningless on Windows, - # where the loader has no equivalent -- see R/zzz.R for how we resolve there - c( -- "-Wl,-L\"$(TBB_LIB)\"", -+ "-L\"$(TBB_LIB)\"", - if (.Platform$OS.type != "windows") - sprintf("-Wl,-rpath,%s", shQuote(tbbLib)), - "-l$(TBB_NAME)", -@@ -240,14 +269,14 @@ pkgLibs <- if (!is.na(tbbLib)) { - } else if (R.version$os == "emscripten") { - - c( -- "-Wl,-Ltbb/build/lib_release", -+ "-Ltbb/build/lib_release", - "-l$(TBB_NAME)" - ) - - } else { - - c( -- "-Wl,-Ltbb/build/lib_release", -+ "-Ltbb/build/lib_release", - "-l$(TBB_NAME)", - "-l$(TBB_MALLOC_NAME)" - ) diff --git a/local/patches/registry.json b/local/patches/registry.json index 1f341b7..e53f1c9 100644 --- a/local/patches/registry.json +++ b/local/patches/registry.json @@ -1,53 +1,13 @@ [ { "package": "RcppParallel", - "versions": "6.2.1", - "platforms": ["*"], + "versions": "*", + "platforms": ["alpine", "ubuntu-2604"], "env": {}, "configure_args": [], "makevars": {}, - "patch": "RcppParallel/force-bundled-tbb-6.2.x.patch", - "reason": "RcppParallel 6.x builds its bundled oneTBB 2022 with cmake, but tools/config/configure.R passes the library dir as '-Wl,-L...', and gcc places its own search dirs (/usr/lib64, /usr/lib/) ahead of anything forwarded with '-Wl,'. Every build image installs a distro libtbb, so '-ltbb' resolves to it: the binary records NEEDED libtbb.so.12 (libtbb.so.2 on el8/el9) instead of the bundled libtbb.so and cannot load on a machine without that exact system TBB. The patch passes a plain '-L' so the bundled build dir wins. As a safeguard it also makes configure.R, src/install.libs.R and R/tbb.R ignore an ambient TBB_ROOT/TBB_LIB/TBB_INC (opt out with BINCRAFT_ALLOW_SYSTEM_TBB=TRUE): the build images stopped exporting them in build-env-images c51ed09, but a stale image or ~/.Renviron would otherwise switch the build back to the system TBB, and R reads ~/.Renviron after the process environment, so no pipeline env can override it. The read sites move between releases, so each entry pins a patch generated against that version. Upstream still uses '-Wl,-L' in 6.2.1; drop these entries once a release passes a plain '-L'." - }, - { - "package": "RcppParallel", - "versions": "6.2.0", - "platforms": ["*"], - "env": {}, - "configure_args": [], - "makevars": {}, - "patch": "RcppParallel/force-bundled-tbb-6.2.x.patch", - "reason": "RcppParallel 6.x builds its bundled oneTBB 2022 with cmake, but tools/config/configure.R passes the library dir as '-Wl,-L...', and gcc places its own search dirs (/usr/lib64, /usr/lib/) ahead of anything forwarded with '-Wl,'. Every build image installs a distro libtbb, so '-ltbb' resolves to it: the binary records NEEDED libtbb.so.12 (libtbb.so.2 on el8/el9) instead of the bundled libtbb.so and cannot load on a machine without that exact system TBB. The patch passes a plain '-L' so the bundled build dir wins. As a safeguard it also makes configure.R, src/install.libs.R and R/tbb.R ignore an ambient TBB_ROOT/TBB_LIB/TBB_INC (opt out with BINCRAFT_ALLOW_SYSTEM_TBB=TRUE): the build images stopped exporting them in build-env-images c51ed09, but a stale image or ~/.Renviron would otherwise switch the build back to the system TBB, and R reads ~/.Renviron after the process environment, so no pipeline env can override it. The read sites move between releases, so each entry pins a patch generated against that version. Upstream still uses '-Wl,-L' in 6.2.1; drop these entries once a release passes a plain '-L'." - }, - { - "package": "RcppParallel", - "versions": "6.1.1", - "platforms": ["*"], - "env": {}, - "configure_args": [], - "makevars": {}, - "patch": "RcppParallel/force-bundled-tbb-6.1.x.patch", - "reason": "RcppParallel 6.x builds its bundled oneTBB 2022 with cmake, but tools/config/configure.R passes the library dir as '-Wl,-L...', and gcc places its own search dirs (/usr/lib64, /usr/lib/) ahead of anything forwarded with '-Wl,'. Every build image installs a distro libtbb, so '-ltbb' resolves to it: the binary records NEEDED libtbb.so.12 (libtbb.so.2 on el8/el9) instead of the bundled libtbb.so and cannot load on a machine without that exact system TBB. The patch passes a plain '-L' so the bundled build dir wins. As a safeguard it also makes configure.R, src/install.libs.R and R/tbb.R ignore an ambient TBB_ROOT/TBB_LIB/TBB_INC (opt out with BINCRAFT_ALLOW_SYSTEM_TBB=TRUE): the build images stopped exporting them in build-env-images c51ed09, but a stale image or ~/.Renviron would otherwise switch the build back to the system TBB, and R reads ~/.Renviron after the process environment, so no pipeline env can override it. The read sites move between releases, so each entry pins a patch generated against that version. Upstream still uses '-Wl,-L' in 6.2.1; drop these entries once a release passes a plain '-L'." - }, - { - "package": "RcppParallel", - "versions": "6.1.0", - "platforms": ["*"], - "env": {}, - "configure_args": [], - "makevars": {}, - "patch": "RcppParallel/force-bundled-tbb-6.1.x.patch", - "reason": "RcppParallel 6.x builds its bundled oneTBB 2022 with cmake, but tools/config/configure.R passes the library dir as '-Wl,-L...', and gcc places its own search dirs (/usr/lib64, /usr/lib/) ahead of anything forwarded with '-Wl,'. Every build image installs a distro libtbb, so '-ltbb' resolves to it: the binary records NEEDED libtbb.so.12 (libtbb.so.2 on el8/el9) instead of the bundled libtbb.so and cannot load on a machine without that exact system TBB. The patch passes a plain '-L' so the bundled build dir wins. As a safeguard it also makes configure.R, src/install.libs.R and R/tbb.R ignore an ambient TBB_ROOT/TBB_LIB/TBB_INC (opt out with BINCRAFT_ALLOW_SYSTEM_TBB=TRUE): the build images stopped exporting them in build-env-images c51ed09, but a stale image or ~/.Renviron would otherwise switch the build back to the system TBB, and R reads ~/.Renviron after the process environment, so no pipeline env can override it. The read sites move between releases, so each entry pins a patch generated against that version. Upstream still uses '-Wl,-L' in 6.2.1; drop these entries once a release passes a plain '-L'." - }, - { - "package": "RcppParallel", - "versions": "6.0.0", - "platforms": ["*"], - "env": {}, - "configure_args": [], - "makevars": {}, - "patch": "RcppParallel/force-bundled-tbb-6.0.0.patch", - "reason": "RcppParallel 6.x builds its bundled oneTBB 2022 with cmake, but tools/config/configure.R passes the library dir as '-Wl,-L...', and gcc places its own search dirs (/usr/lib64, /usr/lib/) ahead of anything forwarded with '-Wl,'. Every build image installs a distro libtbb, so '-ltbb' resolves to it: the binary records NEEDED libtbb.so.12 (libtbb.so.2 on el8/el9) instead of the bundled libtbb.so and cannot load on a machine without that exact system TBB. The patch passes a plain '-L' so the bundled build dir wins. As a safeguard it also makes configure.R, src/install.libs.R and R/tbb.R ignore an ambient TBB_ROOT/TBB_LIB/TBB_INC (opt out with BINCRAFT_ALLOW_SYSTEM_TBB=TRUE): the build images stopped exporting them in build-env-images c51ed09, but a stale image or ~/.Renviron would otherwise switch the build back to the system TBB, and R reads ~/.Renviron after the process environment, so no pipeline env can override it. The read sites move between releases, so each entry pins a patch generated against that version. Upstream still uses '-Wl,-L' in 6.2.1; drop these entries once a release passes a plain '-L'." + "patch": "RcppParallel/disable-tbb.patch", + "reason": "bundled Intel TBB build hangs/fails on musl (Alpine) and newer toolchains (g++ 15 on ubuntu-2604); patch unsets USE_TBB and forces -DRCPP_PARALLEL_USE_TBB=0 so RcppParallel skips the bundled build and uses the TinyThread backend" }, { "package": "fs", @@ -58,5 +18,17 @@ "makevars": {}, "patch": "fs/force-vendored-libuv.patch", "reason": "fs 2.x configure links system libuv whenever pkg-config finds libuv-devel (installed as a build-time sysreq), producing an fs.so with NEEDED libuv.so.1. That binary fails to dyn.load on consumer machines lacking runtime libuv, because install.packages/renv do not install SystemRequirements (only pak does, and only in the build container). The patch short-circuits configure to copy src/Makevars.vendor and build the bundled static libuv (needs cmake) so the binary is self-contained on every platform. An env/pkg-config override was tried first but the rebuilt binary still linked libuv.so.1, so a source patch is used instead." + }, + { + "package": "rstan", + "versions": "*", + "platforms": ["*"], + "env": {}, + "configure_args": [], + "makevars": { + "CPPFLAGS": "-DTBB_INTERFACE_NEW -I/usr/local/include" + }, + "patch": null, + "reason": "StanHeaders' init_threadpool_tbb.hpp unconditionally includes the legacy (removed in oneTBB 2021+) for version detection, breaking compilation of Module.cpp against the bundled oneTBB. Pre-defining TBB_INTERFACE_NEW skips that include and selects the modern tbb/global_control.h + tbb/task_arena.h path that the bundled TBB provides (-I/usr/local/include preserves the default CPPFLAGS the override replaces)" } ] diff --git a/local/proposal-tracking-lib.R b/local/proposal-tracking-lib.R index 28f65db..4e8a468 100644 --- a/local/proposal-tracking-lib.R +++ b/local/proposal-tracking-lib.R @@ -6,37 +6,6 @@ # Kept free of DB/HTTP/clock so it can be sourced by the proposer, the tracker # entrypoint, and the unit tests. Timestamps are passed in by callers. -# Split proposal candidates into the ones safe to emit and the ambiguous ones. -# A candidate is a list with at least `package` and `signature`. A package that -# maps to more than one distinct auto-proposable signature is genuinely -# ambiguous (two conflicting fix tiers, e.g. makevars vs source patch): emitting -# both would create colliding registry entries, so those are routed to human -# triage instead of guessed at. Returns list(keep = ..., ambiguous = ...), where -# `ambiguous` is a named list of package -> the distinct signatures seen. -dedupe_candidates <- function(candidates) { - if (length(candidates) == 0L) { - return(list(keep = list(), ambiguous = list())) - } - pkgs <- vapply(candidates, function(c) as.character(c$package), character(1L)) - by_pkg <- split(candidates, pkgs) - keep <- list() - ambiguous <- list() - for (pkg in names(by_pkg)) { - cs <- by_pkg[[pkg]] - sigs <- unique(vapply( - cs, - function(c) as.character(c$signature), - character(1L) - )) - if (length(sigs) == 1L) { - keep[[length(keep) + 1L]] <- cs[[1L]] # one signature -> take the first - } else { - ambiguous[[pkg]] <- sigs - } - } - list(keep = keep, ambiguous = ambiguous) -} - # Stable identity of a ledger record: one proposal per (package, signature). ledger_key <- function(record) { paste0( @@ -131,100 +100,4 @@ retirement_candidates <- function(registry_entries, failing_pkgs) { vapply(keep, function(e) as.character(e$package), character(1L)) } -# Discovery view: the failure groups the classifier could NOT auto-propose, so -# they get the same visibility as proposals. `report` is a build_triage_report -# result. Returns the unclassified groups (unknown signature -> candidates for a -# new signature) ranked by build count, capped to `max_groups`, each with up to -# `max_pkgs` example packages. `dropped_groups`/`packages_truncated` record any -# cap so nothing is silently hidden. -unclassified_summary <- function(report, max_groups = 30L, max_pkgs = 15L) { - un <- Filter(function(g) !isTRUE(g$matched), report) - un <- un[order(-vapply(un, function(g) g$build_count, integer(1L)))] - shown <- utils::head(un, max_groups) - groups <- lapply(shown, function(g) { - pkgs <- g$packages - list( - fingerprint = g$fingerprint, - build_count = g$build_count, - n_packages = length(pkgs), - packages = utils::head(pkgs, max_pkgs), - packages_truncated = length(pkgs) > max_pkgs, - platforms = g$platforms - ) - }) - list( - groups = groups, - total_groups = length(un), - dropped_groups = max(0L, length(un) - length(shown)), - total_builds = sum(vapply(un, function(g) g$build_count, integer(1L))) - ) -} - -# Aggregate blocked packages across ALL groups by the dependency they wait on, -# so one dependency (RcppParallel, rstan, sf, ...) is a single line -- deduped -# and ranked by how many distinct dependents it blocks -- instead of repeating -# once per fingerprint group. Reads each group's `blocked_map` (package -> the -# dependency it is blocked on). Returns records sorted by dependent count desc, -# each with up to `max_pkgs` example dependents. -blocked_by_dependency <- function(report, max_pkgs = 15L) { - acc <- list() # dependency -> character vector of dependent packages - for (g in report) { - bm <- g$blocked_map - if (is.null(bm) || length(bm) == 0L) { - next - } - for (pkg in names(bm)) { - for (dep in as.character(unlist(bm[[pkg]]))) { - acc[[dep]] <- unique(c(acc[[dep]], pkg)) - } - } - } - if (length(acc) == 0L) { - return(list()) - } - out <- lapply(names(acc), function(dep) { - pkgs <- acc[[dep]] - list( - dependency = dep, - n_packages = length(pkgs), - packages = utils::head(pkgs, max_pkgs), - packages_truncated = length(pkgs) > max_pkgs - ) - }) - out[order(-vapply(out, function(x) x$n_packages, integer(1L)))] -} - -# Does a registry entry's `platforms` apply to a build on `os` (e.g. -# "ubuntu-2604")? Mirrors bincraft's token match: an entry applies if any of its -# platform tokens is "*", the OS codename, or the distro family ("ubuntu"). -entry_applies_to_os <- function(entry_platforms, os) { - toks <- as.character(unlist(entry_platforms)) - family <- sub("-.*$", "", os) # ubuntu-2604 -> ubuntu - any(toks %in% c("*", os, family)) -} - -# Registry entries present in `current` but not in `base` (matched on -# package|platforms|versions), optionally restricted to those that apply to a -# given `os`. Used by the trial-build gate to build only the entries a PR adds. -new_registry_packages <- function(current, base, os = NULL) { - key <- function(e) { - sprintf( - "%s|%s|%s", - e$package %||% "?", - paste(sort(as.character(unlist(e$platforms))), collapse = ","), - e$versions %||% "?" - ) - } - base_keys <- vapply(base %||% list(), key, character(1L)) - added <- Filter(function(e) !(key(e) %in% base_keys), current %||% list()) - if (!is.null(os)) { - added <- Filter(function(e) entry_applies_to_os(e$platforms, os), added) - } - unique(vapply( - added, - function(e) as.character(e$package %||% ""), - character(1L) - )) -} - `%||%` <- function(a, b) if (is.null(a)) b else a diff --git a/local/proposal-tracking.R b/local/proposal-tracking.R index f8e1284..60bbd10 100644 --- a/local/proposal-tracking.R +++ b/local/proposal-tracking.R @@ -1,16 +1,12 @@ #!/usr/bin/env Rscript # Feedback loop for the failure classifier (issue #115, step 4): report the -# signature hit rate, proposed-vs-merged status, registry entries that look -# retirable, and -- so the classifier's blind spots get the same visibility as -# its proposals -- the failures it could NOT classify (candidates for new -# signatures) plus the groups blocked on a dependency build. -# Read-only on the DB/registry; the only optional write is the Forgejo issue. +# signature hit rate, proposed-vs-merged status, and registry entries that look +# retirable, so the rule set can improve and stale entries can be pruned. +# Read-only: queries `single_builds` and reads the registry + proposals ledger. # # Usage: -# PGPASS=... Rscript local/proposal-tracking.R [--json PATH] [--open-issue] -# --open-issue post/update a Forgejo issue listing the unclassified and -# dependency-blocked failures (needs FORGEJO_TOKEN) +# PGPASS=... Rscript local/proposal-tracking.R [--json PATH] options(error = function() { cat("ERROR:", geterrmessage(), "\n", file = stdout()) @@ -37,7 +33,6 @@ json_out <- local({ i <- match("--json", args) if (!is.na(i) && i < length(args)) args[[i + 1L]] else NA_character_ }) -do_issue <- "--open-issue" %in% args if (nchar(Sys.getenv("PGPASS")) == 0L) { stop("PGPASS env var is not set; a DB password is required.") @@ -124,52 +119,12 @@ if (length(retire) > 0L) { cat(" (none)\n") } -# --------------------------------------------------------------------------- -# Blind spots: failures the classifier could not auto-propose. -# --------------------------------------------------------------------------- -blocked <- blocked_by_dependency(report) -unmatched <- unclassified_summary(report) - -cat("\nBlocked on a dependency (fix the dependency, not each dependent):\n") -if (length(blocked) > 0L) { - for (b in blocked) { - cat(sprintf(" %-20s %5d dependent(s)\n", b$dependency, b$n_packages)) - } -} else { - cat(" (none)\n") -} - -cat(sprintf( - "\nUnclassified failures (candidates for new signatures): %d group(s), %d builds.\n", - unmatched$total_groups, - unmatched$total_builds -)) -cat(strrep("-", 60L), "\n", sep = "") -for (g in unmatched$groups) { - cat(sprintf( - " [%d builds | %d pkgs] %s\n e.g. %s%s\n", - g$build_count, - g$n_packages, - g$fingerprint, - toString(g$packages), - if (isTRUE(g$packages_truncated)) ", ..." else "" - )) -} -if (unmatched$dropped_groups > 0L) { - cat(sprintf( - " (+%d more unclassified group(s) not shown)\n", - unmatched$dropped_groups - )) -} - if (!is.na(json_out)) { jsonlite::write_json( list( signature_hit_rate = hit, proposed_vs_merged = pvm, - retirement_candidates = retire, - blocked = blocked, - unclassified = unmatched + retirement_candidates = retire ), json_out, auto_unbox = TRUE, @@ -178,127 +133,3 @@ if (!is.na(json_out)) { ) cat(sprintf("\nWrote metrics to %s\n", json_out)) } - -# --------------------------------------------------------------------------- -# Optionally publish the blind-spots to a Forgejo tracking issue. -# --------------------------------------------------------------------------- -if (do_issue) { - forgejo_token <- Sys.getenv("FORGEJO_TOKEN") - if (nchar(forgejo_token) == 0L) { - stop("--open-issue requires FORGEJO_TOKEN.") - } - suppressPackageStartupMessages(library(httr2, quietly = TRUE)) - forgejo_base <- "https://git.devxy.io/api/v1" - repo <- "devxy/build-cran-binaries" - issue_title <- "Unclassified build failures (needs signatures) (#115)" - now <- format(Sys.time(), "%Y-%m-%d %H:%M:%S") - - body_lines <- c( - sprintf("_Generated %s from `single_builds` failures._", now), - "", - "Failures the classifier could **not** auto-propose a fix for.", - "Each unclassified group is a candidate for a new signature in `local/failing-builds-classify.R`; the blocked groups clear once the named dependency builds.", - "" - ) - - body_lines <- c(body_lines, "## Blocked on a dependency", "") - if (length(blocked) > 0L) { - for (b in blocked) { - body_lines <- c( - body_lines, - sprintf( - "- **%s**: %d dependent(s) (e.g. %s%s)", - b$dependency, - b$n_packages, - toString(b$packages), - if (isTRUE(b$packages_truncated)) ", ..." else "" - ) - ) - } - } else { - body_lines <- c(body_lines, "_None._") - } - - body_lines <- c( - body_lines, - "", - sprintf( - "## Unclassified failures (%d groups, %d builds)", - unmatched$total_groups, - unmatched$total_builds - ), - "" - ) - if (length(unmatched$groups) > 0L) { - for (g in unmatched$groups) { - body_lines <- c( - body_lines, - sprintf( - "### %d builds / %d pkg(s)", - g$build_count, - g$n_packages - ), - "", - sprintf("Fingerprint: `%s`", g$fingerprint), - sprintf( - "Packages: %s%s", - toString(g$packages), - if (isTRUE(g$packages_truncated)) ", ..." else "" - ), - sprintf("Platforms: %s", toString(g$platforms)), - "" - ) - } - if (unmatched$dropped_groups > 0L) { - body_lines <- c( - body_lines, - sprintf("_(+%d more group(s) not shown.)_", unmatched$dropped_groups) - ) - } - } else { - body_lines <- c(body_lines, "_None -- every failure is classified._") - } - new_body <- paste(body_lines, collapse = "\n") - - search_url <- sprintf( - "%s/repos/%s/issues?type=issues&state=open&q=%s&limit=50", - forgejo_base, - repo, - utils::URLencode(issue_title, reserved = TRUE) - ) - existing <- httr2::request(search_url) |> - httr2::req_headers(Authorization = paste("token", forgejo_token)) |> - httr2::req_perform() |> - httr2::resp_body_json(simplifyVector = FALSE) - match_idx <- which(vapply( - existing, - function(x) identical(x$title, issue_title), - logical(1L) - )) - if (length(match_idx) > 0L) { - num <- existing[[match_idx[1]]]$number - httr2::request(sprintf("%s/repos/%s/issues/%d", forgejo_base, repo, num)) |> - httr2::req_headers( - Authorization = paste("token", forgejo_token), - `Content-Type` = "application/json" - ) |> - httr2::req_body_json(list(body = new_body)) |> - httr2::req_method("PATCH") |> - httr2::req_perform() - cat(sprintf("\nUpdated tracking issue #%d.\n", num)) - } else { - created <- httr2::request(sprintf( - "%s/repos/%s/issues", - forgejo_base, - repo - )) |> - httr2::req_headers( - Authorization = paste("token", forgejo_token), - `Content-Type` = "application/json" - ) |> - httr2::req_body_json(list(title = issue_title, body = new_body)) |> - httr2::req_perform() |> - httr2::resp_body_json() - cat(sprintf("\nOpened tracking issue #%d.\n", created$number)) - } -} diff --git a/local/propose-patches.R b/local/propose-patches.R index 360df9f..20583ba 100644 --- a/local/propose-patches.R +++ b/local/propose-patches.R @@ -15,17 +15,10 @@ # # Usage: # PGPASS=... Rscript local/propose-patches.R [--platform P] [--arch A] [--min N] -# [--limit N] # (default) print candidates + validation, take no action # --write append candidates to local/patches/registry.json and # the proposals ledger (commit + open a PR yourself) # --open-issue post/update a Forgejo tracking issue (needs FORGEJO_TOKEN) -# --open-pr write the entries, push the `auto/registry-patch-proposals` -# branch, and open/update a PR autonomously. Uses -# FORGEJO_TOKEN for both the push and the PR API. The -# `trial-build-registry` pipeline is the merge gate. -# --limit N only act on the top-N candidates by failure volume -# (bounded batch; the rest are picked up on the next run) # --json PATH also write the machine-readable candidate list to PATH options(error = function() { @@ -65,8 +58,6 @@ if (is.na(min_count)) { } do_write <- "--write" %in% args do_issue <- "--open-issue" %in% args -do_pr <- "--open-pr" %in% args -limit <- suppressWarnings(as.integer(opt_val("--limit", NA_character_))) if (nchar(Sys.getenv("PGPASS")) == 0L) { stop("PGPASS env var is not set; a DB password is required.") @@ -74,8 +65,6 @@ if (nchar(Sys.getenv("PGPASS")) == 0L) { registry_file <- file.path(script_dir, "patches", "registry.json") ledger_file <- file.path(script_dir, "patches", "proposals-log.json") -# Branch the autonomous PR reuses, so re-runs update one PR instead of piling up. -pr_branch <- "auto/registry-patch-proposals" # --------------------------------------------------------------------------- # Query failing builds (same shape as failing-builds-report.R) @@ -129,8 +118,7 @@ registered_pkgs <- unique(vapply( report <- build_triage_report(failures, registered_pkgs) report <- Filter(function(r) r$build_count >= min_count, report) -# Flatten auto-proposable groups into candidate records, carrying the group's -# build volume so we can prioritise the highest-impact fixes. +# Flatten auto-proposable groups into candidate records. candidates <- list() for (r in report) { if (is.null(r$proposed_entries)) { @@ -142,68 +130,18 @@ for (r in report) { signature = r$signature, confidence = r$confidence, tier = r$tier, - build_count = r$build_count, entry = r$proposed_entries[[pkg]] ) } } -# Packages blocked on a dependency are reported (aggregated by dependency, -# ranked by impact), not proposed: fixing the named dependency clears the batch. -blocked <- blocked_by_dependency(report) -if (length(blocked) > 0L) { - n_blocked_pkgs <- length(unique(unlist( - lapply(report, function(r) r$blocked_packages) - ))) - cat(sprintf( - "\nBlocked on a dependency (%d dependencies block %d dependents; fix the dependency, not each dependent):\n", - length(blocked), - n_blocked_pkgs - )) - for (b in blocked) { - cat(sprintf(" %-20s %5d dependent(s)\n", b$dependency, b$n_packages)) - } -} - -# A package that maps to more than one auto-proposable signature is ambiguous -# (conflicting fix tiers) and would collide on the same registry key; route it -# to human triage instead of emitting both. -split_candidates <- dedupe_candidates(candidates) -candidates <- split_candidates$keep -if (length(split_candidates$ambiguous) > 0L) { - cat("\nAmbiguous (multiple signatures) -> human triage, not proposed:\n") - for (pkg in names(split_candidates$ambiguous)) { - cat(sprintf( - " %s: %s\n", - pkg, - toString(split_candidates$ambiguous[[pkg]]) - )) - } -} - if (length(candidates) == 0L) { cat( - "\nNo auto-proposable candidates (nothing classified, safe, unregistered, and unambiguous).\n" + "No auto-proposable candidates (nothing classified, safe, and unregistered).\n" ) q(status = 0) } -# Prioritise by failure volume, then apply --limit so one run tackles a bounded -# batch (the rest are picked up on the next run). -candidates <- candidates[order( - -vapply(candidates, function(c) c$build_count %||% 0L, integer(1L)) -)] -deferred <- 0L -if (!is.na(limit) && limit >= 0L && length(candidates) > limit) { - deferred <- length(candidates) - limit - candidates <- utils::head(candidates, limit) - cat(sprintf( - "\nLimiting to top %d candidate(s) by failure volume; %d deferred to a later run.\n", - limit, - deferred - )) -} - cat(sprintf( "\n%d candidate registry %s:\n", length(candidates), @@ -417,162 +355,8 @@ if (do_write) { cat(sprintf("Opened tracking issue #%d.\n", created$number)) } save_ledger(merge_ledger(load_ledger(), new_ledger_records)) -} else if (do_pr) { - forgejo_token <- Sys.getenv("FORGEJO_TOKEN") - if (nchar(forgejo_token) == 0L) { - stop("--open-pr requires FORGEJO_TOKEN (to open the PR).") - } - suppressPackageStartupMessages(library(httr2, quietly = TRUE)) - forgejo_base <- "https://git.devxy.io/api/v1" - repo <- "devxy/build-cran-binaries" - - # Write the entries + ledger, then commit them onto the reused auto branch. - jsonlite::write_json( - merged, - registry_file, - auto_unbox = TRUE, - pretty = TRUE, - null = "null" - ) - save_ledger(merge_ledger(load_ledger(), new_ledger_records)) - - git <- function(...) { - # system2() with captured output runs via /bin/sh, so shell-quote every arg - # (commit messages contain "()", refs contain "^{}", etc.). - st <- system2("git", shQuote(c(...)), stdout = TRUE, stderr = TRUE) - if (!identical(attr(st, "status"), NULL)) { - stop(sprintf( - "git %s failed:\n%s", - paste(..., collapse = " "), - paste(st, collapse = "\n") - )) - } - invisible(st) - } - git("config", "user.name", Sys.getenv("GIT_USER", "devxy-bot")) - git( - "config", - "user.email", - Sys.getenv("GIT_EMAIL", "bot@devxy.io") - ) - git("checkout", "-B", pr_branch) - git( - "add", - file.path(script_dir, "patches", "registry.json"), - ledger_file - ) - git( - "commit", - "-m", - sprintf( - "feat(patches): auto-propose %d registry %s from classified failures", - length(candidate_entries), - if (length(candidate_entries) == 1L) "entry" else "entries" - ) - ) - # Push over HTTPS with FORGEJO_TOKEN (same token used for the PR API), so no - # separate write-scoped secret is needed. The read-only `origin` clone URL - # can't push, so build an authenticated URL explicitly. - push_target <- sprintf( - "https://%s:%s@git.devxy.io/%s.git", - Sys.getenv("GIT_REMOTE_USER", "pat-s"), - forgejo_token, - repo - ) - git("push", "-f", push_target, sprintf("HEAD:refs/heads/%s", pr_branch)) - - pr_title <- sprintf( - "feat(patches): auto-proposed registry patches (%s)", - now - ) - body_lines <- c( - sprintf( - "_Auto-generated %s by `local/propose-patches.R --open-pr` from classified `single_builds` failures._", - now - ), - "", - sprintf( - "Adds %d known-lever registry %s (top by failure volume%s).", - length(candidate_entries), - if (length(candidate_entries) == 1L) "entry" else "entries", - if (deferred > 0L) { - sprintf("; %d deferred to a later run", deferred) - } else { - "" - } - ), - "", - "**Merge gate:** the `trial-build-registry` pipeline builds each new entry in its target build-env image; merge only once it is green.", - "Novel source diffs and unknown signatures are never auto-proposed.", - "", - "| package | signature | platforms |", - "| --- | --- | --- |" - ) - for (c in candidates) { - body_lines <- c( - body_lines, - sprintf( - "| %s | %s | %s |", - c$package, - c$signature, - toString(as.character(c$entry$platforms)) - ) - ) - } - new_body <- paste(body_lines, collapse = "\n") - - # One PR per reused branch: update if open, else create. - pulls_url <- sprintf( - "%s/repos/%s/pulls?state=open&limit=50", - forgejo_base, - repo - ) - open_pulls <- httr2::request(pulls_url) |> - httr2::req_headers(Authorization = paste("token", forgejo_token)) |> - httr2::req_perform() |> - httr2::resp_body_json(simplifyVector = FALSE) - match_idx <- which(vapply( - open_pulls, - function(p) identical(p$head$ref, pr_branch), - logical(1L) - )) - if (length(match_idx) > 0L) { - num <- open_pulls[[match_idx[1]]]$number - httr2::request(sprintf("%s/repos/%s/pulls/%d", forgejo_base, repo, num)) |> - httr2::req_headers( - Authorization = paste("token", forgejo_token), - `Content-Type` = "application/json" - ) |> - httr2::req_body_json(list(title = pr_title, body = new_body)) |> - httr2::req_method("PATCH") |> - httr2::req_perform() - cat(sprintf("\nUpdated auto-patch PR #%d (branch %s).\n", num, pr_branch)) - } else { - created <- httr2::request(sprintf( - "%s/repos/%s/pulls", - forgejo_base, - repo - )) |> - httr2::req_headers( - Authorization = paste("token", forgejo_token), - `Content-Type` = "application/json" - ) |> - httr2::req_body_json(list( - title = pr_title, - head = pr_branch, - base = "main", - body = new_body - )) |> - httr2::req_perform() |> - httr2::resp_body_json() - cat(sprintf( - "\nOpened auto-patch PR #%d (branch %s).\n", - created$number, - pr_branch - )) - } } else { cat( - "\nDry run: no changes made. Re-run with --write, --open-issue, or --open-pr to act.\n" + "\nDry run: no changes made. Re-run with --write or --open-issue to act.\n" ) } diff --git a/local/prune-stale-flat-binaries.R b/local/prune-stale-flat-binaries.R deleted file mode 100644 index aea97b4..0000000 --- a/local/prune-stale-flat-binaries.R +++ /dev/null @@ -1,175 +0,0 @@ -### Remove flat-slot binaries recorded as unable to load under some R minor. -# -# Usage: -# Rscript local/prune-stale-flat-binaries.R [--apply] [pkg ...] -# -# The list comes from `abi_flat_safety`, written by the flat-safety backfill -# from the binaries' own symbols, so it names exactly the objects that fail at -# dyn.load() somewhere. It deliberately does *not* use -# `abi_cache_risky_packages()`: that source heuristic calls 93% of compiled -# packages risky where the binaries say 4.3%, and pruning on it would delete -# roughly 3000 working objects per slot to fix about 230 broken ones. Selecting -# per object rather than per package is the same correction #129 made to the -# index writer. -# -# `upload_single_binary()` has written a non-portable package only to the -# per-minor slot since v5.3.0, so this is a one-time cleanup of objects -# uploaded before that, not a recurring sweep. The flat slot is what every -# client that cannot be routed by R minor receives, and such a binary was built -# under whichever R was primary at the time, so it fails at load under any -# minor that dropped a symbol it uses: -# -# base64enc.so: undefined symbol: SETLENGTH -# -# Deleting the flat object does not create a hole: bincraft's source fallback -# publishes the CRAN source under the same key, so the package stays installable -# everywhere and compiles against the client's own R. Clients that are routed -# keep getting the per-minor binary. -# -# Dry run by default. Nothing is deleted without --apply. - -options(error = function() { - cat("ERROR:", geterrmessage(), "\n", file = stdout()) - traceback(2) - q(status = 1) -}) - -library(bincraft, quietly = TRUE) - -args <- commandArgs(trailingOnly = TRUE) -apply_changes <- "--apply" %in% args -args <- args[args != "--apply"] -if (length(args) < 2L) { - stop( - "usage: prune-stale-flat-binaries.R [--apply] [pkg ...]", - call. = FALSE - ) -} -arch <- args[[1L]] -codename <- args[[2L]] -explicit <- args[-c(1L, 2L)] - -bucket <- "devxy-rpkgs-binaries" -s3fs::s3_file_system( - aws_access_key_id = Sys.getenv("B2_S3_ACCESS_KEY"), - aws_secret_access_key = Sys.getenv("B2_S3_SECRET_KEY"), - endpoint = "https://s3.eu-central-003.backblazeb2.com", - region_name = "eu-central-003", - refresh = TRUE -) - -contrib <- sprintf("%s/%s/%s/latest/src/contrib", bucket, arch, codename) -objects <- s3fs::s3_dir_ls(contrib, recurse = TRUE) -relative <- sub(sprintf("^(s3://)?%s/", contrib), "", objects) - -is_tarball <- grepl("\\.tar\\.gz$", relative) -# Flat objects only: no `/` prefix and not under Archive/. -is_flat <- is_tarball & !grepl("/", relative, fixed = TRUE) -flat_files <- relative[is_flat] -flat_pkg <- sub("_.*$", "", flat_files) - -if (length(explicit) > 0L) { - doomed <- flat_files[flat_pkg %in% explicit] - cat(sprintf( - "%s/%s: %d flat binaries, %d belong to the %d named package(s)\n", - arch, - codename, - length(flat_files), - length(doomed), - length(explicit) - )) -} else { - con <- DBI::dbConnect( - RPostgres::Postgres(), - dbname = "build_metadata", - host = "r-binaries.devxy.io", - port = 15432L, - user = "rpkgs", - password = Sys.getenv("PGPASS"), - sslmode = "require" - ) - on.exit(try(DBI::dbDisconnect(con), silent = TRUE), add = TRUE) - - # Every recorded-unsafe row, whichever minor it fails under: the flat object - # is served to clients on all of them, so one failing minor condemns it. - rows <- DBI::dbGetQuery( - con, - paste0( - "SELECT package, version FROM abi_flat_safety ", - "WHERE NOT safe AND codename = $1 AND arch = $2" - ), - params = list(codename, arch) - ) - if (nrow(rows) == 0L) { - stop( - "No unsafe objects are recorded for this slot. Refusing to proceed: that is indistinguishable from a slot whose flat-safety backfill never ran, and pruning nothing while reporting success would hide it.", # nolint - call. = FALSE - ) - } - - # Match the object, not the package. A package can have a safe flat binary at - # one version and an unsafe one at another, and deleting both would throw - # away a working binary. - unsafe_objects <- sprintf("%s_%s", rows$package, rows$version) - doomed <- flat_files[sub("\\.tar\\.gz$", "", flat_files) %in% unsafe_objects] - cat(sprintf( - "%s/%s: %d flat binaries, %d recorded unable to load under some R minor\n", - arch, - codename, - length(flat_files), - length(doomed) - )) -} - -# Never remove the last artifact of a package. The source fallback republishes -# the CRAN source under this key, but only for packages CRAN still ships, so -# confirm the source exists before deleting the binary. -cran <- tryCatch( - { - con <- gzcon(url( - "https://cloud.r-project.org/src/contrib/PACKAGES.gz", - open = "rb" - )) - on.exit(close(con), add = TRUE) - read.dcf(con, fields = "Package")[, "Package"] - }, - error = function(e) character(0) -) -if (length(cran) == 0L) { - stop("Could not read CRAN's index; refusing to prune blind.", call. = FALSE) -} - -orphaned <- doomed[!(sub("_.*$", "", doomed) %in% cran)] -if (length(orphaned) > 0L) { - cat(sprintf( - "Keeping %d object(s) whose package is no longer on CRAN, so there is no source to fall back to: %s\n", - length(orphaned), - paste(utils::head(orphaned, 5L), collapse = ", ") - )) - doomed <- setdiff(doomed, orphaned) -} - -if (length(doomed) == 0L) { - cat("Nothing to prune.\n") - quit(status = 0) -} - -for (f in doomed) { - cat(sprintf(" %s %s\n", if (apply_changes) "DELETE" else "would delete", f)) -} - -if (!apply_changes) { - cat(sprintf( - "\nDry run: %d object(s) left in place. Re-run with --apply to delete.\n", - length(doomed) - )) - quit(status = 0) -} - -for (f in doomed) { - s3fs::s3_file_delete(file.path(contrib, f)) -} -cat(sprintf( - "Deleted %d flat binary object(s). Re-index the slot so the source fallback republishes them.\n", - length(doomed) -)) diff --git a/local/r-minor-helpers.R b/local/r-minor-helpers.R index 65aafdf..6f01c29 100644 --- a/local/r-minor-helpers.R +++ b/local/r-minor-helpers.R @@ -31,3 +31,42 @@ parse_build_args <- function(args) { ncpus = as.integer(pos[3L]) ) } + +# Bound the {pkgcache} metadata dir, which otherwise grows without limit: the +# "patched" repo mints a new content hash on every PACKAGES change, so each build +# writes a fresh ~70 MB _metadata/pkgs-.rds (+ patched-/) that is +# never reused. Keep the `keep` newest entries by mtime; only remove entries +# older than `min_age_secs`, so a concurrent split-job's in-flight files are +# never deleted (each build uses a unique hash, so aged entries are +# unreferenced). Stable repo dirs (CRAN-*, BioC*, INLA-*) and pkg/ downloads are +# not matched and thus preserved. Returns the number of entries removed. +trim_pkgcache_metadata <- function(cache_dir = Sys.getenv("R_PKG_CACHE_DIR"), + keep = 20L, + min_age_secs = 600) { + meta <- file.path(cache_dir, "R", "pkgcache", "_metadata") + if (!nzchar(cache_dir) || !dir.exists(meta)) { + return(0L) + } + entries <- c( + Sys.glob(file.path(meta, "patched-*")), + Sys.glob(file.path(meta, "pkgs-*.rds")) + ) + if (length(entries) == 0L) { + return(0L) + } + info <- file.info(entries) + order_new_first <- order(info$mtime, decreasing = TRUE) + ranked <- entries[order_new_first] + ranked_mtime <- info$mtime[order_new_first] + if (length(ranked) <= keep) { + return(0L) + } + candidates <- ranked[(keep + 1L):length(ranked)] + candidate_age <- as.numeric(Sys.time()) - as.numeric(ranked_mtime[(keep + 1L):length(ranked)]) + removable <- candidates[candidate_age >= min_age_secs] + if (length(removable) == 0L) { + return(0L) + } + unlink(removable, recursive = TRUE, force = TRUE) + length(removable) +} diff --git a/local/rebuild-missing-helpers.R b/local/rebuild-missing-helpers.R deleted file mode 100644 index 16a588e..0000000 --- a/local/rebuild-missing-helpers.R +++ /dev/null @@ -1,87 +0,0 @@ -# Pure helpers for local/rebuild-missing.R, kept separate so local/tests can -# source them without executing a rebuild. - -# Interleaved slice of the rebuild list. -# -# The list is alphabetical and build cost clusters by name (Rcpp*, Bioc*, -# rstan*), so contiguous thirds would be badly unbalanced. Interleaving also -# makes each shard's progress counter representative of the slot as a whole. -shard_slice <- function(pkgs, split_into, split_index) { - split_into <- as.integer(split_into) - split_index <- as.integer(split_index) - if (is.na(split_into) || is.na(split_index)) { - stop("shard_slice(): split_into and split_index must be integers") - } - if (split_into < 1L || split_index < 1L || split_index > split_into) { - stop(sprintf( - "shard_slice(): need 1 <= split_index <= split_into, got %s of %s", - split_index, - split_into - )) - } - # seq() errors on a descending range, which is what an empty list or a shard - # index past the end would produce. - if (length(pkgs) < split_index) { - return(pkgs[0L]) - } - pkgs[seq.int(split_index, length(pkgs), by = split_into)] -} - -# Packages that still need building, decided from the bucket rather than from -# remembered progress. -# -# This is bincraft's `check_s3_root_package()` evaluated in bulk: an object -# whose ETag equals CRAN's published MD5sum is byte-identical to CRAN's source, -# so the build that was supposed to replace it has not happened yet. -# -# `etag_by_file` named by `_.tar.gz`, values are unquoted ETags -# `cran_version` named by package -# `cran_md5` named by `_` -# -# Unknown always means "already a binary", never "rebuild it", so an unreadable -# CRAN index or a multipart ETag can never mass-schedule work. -outstanding_packages <- function(pkgs, etag_by_file, cran_version, cran_md5) { - if (length(pkgs) == 0L) { - return(pkgs) - } - - # An empty table indexes to zero length rather than to NA, which would - # recycle the whole result away and silently report "nothing to build". - lookup <- function(table, key) { - if (length(table) == 0L) { - return(rep(NA_character_, length(key))) - } - unname(as.character(table[key])) - } - - version <- lookup(cran_version, pkgs) - file <- sprintf("%s_%s.tar.gz", pkgs, version) - etag <- lookup(etag_by_file, file) - md5 <- lookup(cran_md5, paste(pkgs, version, sep = "_")) - - # No CRAN version means the package cannot be resolved to a tarball at all; - # leave it in and let bincraft report why. - unresolved <- is.na(version) - # No object at the key: never built, so it is outstanding by definition. - absent <- !unresolved & is.na(etag) - # A multipart upload carries a compound ETag rather than an MD5. - unknown <- !is.na(etag) & grepl("-", etag, fixed = TRUE) - - is_source <- !unresolved & - !is.na(etag) & - !unknown & - !is.na(md5) & - etag == md5 - - pkgs[unresolved | absent | is_source] -} - -parse_rebuild_args <- function(args) { - pos <- args[!startsWith(args, "--")] - budget <- as.numeric(pos[3L]) - list( - split_into = as.integer(pos[1L]), - split_index = as.integer(pos[2L]), - budget_hours = if (is.na(budget)) 20 else budget - ) -} diff --git a/local/rebuild-missing.R b/local/rebuild-missing.R deleted file mode 100644 index 369024e..0000000 --- a/local/rebuild-missing.R +++ /dev/null @@ -1,194 +0,0 @@ -### Rebuild one shard of a slot's missing-binary list. -# -# Usage: Rscript local/rebuild-missing.R [budget_hours] -# -# The list itself comes from local/fetch-rebuild-packages-from-issue.R, which -# writes $REBUILD_PKG_LIST (default /tmp/rebuild_pkgs.txt). -# -# Two properties matter here and are the reason this is a script rather than an -# `R -q -e` argument in the pipeline: -# -# * it is restartable. The outstanding set is re-derived from the bucket on -# every start, so a shard that died resumes where it stopped without any -# progress file, and without replaying thousands of per-package HEADs. -# * it terminates. A wall-clock budget stops the loop cleanly instead of the -# run having to be killed, which is what previously skipped the re-index and -# CDN purge and left rebuilt binaries hidden behind stale edge copies. - -options(error = function() { - cat("ERROR:", geterrmessage(), "\n", file = stdout()) - traceback(2) - q(status = 1) -}) - -library(bincraft, quietly = TRUE) - -source(file.path("local", "rebuild-missing-helpers.R")) - -args <- parse_rebuild_args(commandArgs(trailingOnly = TRUE)) -if (is.na(args$split_into) || is.na(args$split_index)) { - stop("usage: rebuild-missing.R [budget_hours]") -} - -list_file <- Sys.getenv("REBUILD_PKG_LIST", "/tmp/rebuild_pkgs.txt") -pkgs <- if (file.exists(list_file)) readLines(list_file) else character(0) -pkgs <- pkgs[nzchar(pkgs)] -if (length(pkgs) == 0L) { - cat("Nothing to rebuild\n") - q("no") -} - -excluded <- jsonlite::fromJSON("local/excluded-packages.json")[["package"]] -pkgs <- setdiff(pkgs, excluded) - -mine <- shard_slice(pkgs, args$split_into, args$split_index) -cat(sprintf( - "Shard %s/%s: %s of %s listed packages\n", - args$split_index, - args$split_into, - length(mine), - length(pkgs) -)) - -### Resume: ask the bucket what is still outstanding - -codename <- bincraft::set_codename(NULL) -local_machine <- Sys.info()[["machine"]] -arch <- if (grepl("arm64|aarch64", local_machine)) "arm64" else "amd64" -slot_dir <- sprintf( - "devxy-rpkgs-binaries/%s/%s/latest/src/contrib", - arch, - codename -) - -s3fs::s3_file_system( - aws_access_key_id = Sys.getenv("B2_S3_ACCESS_KEY"), - aws_secret_access_key = Sys.getenv("B2_S3_SECRET_KEY"), - endpoint = "https://s3.eu-central-003.backblazeb2.com", - region_name = "eu-central-003", - refresh = TRUE -) - -# One paginated listing instead of a HEAD per package. Not recursed: the -# rebuild passes no `is_r_minor_sensitive`, so it only ever targets the flat -# path, and the resume filter matches that scope deliberately. -info <- tryCatch(s3fs::s3_dir_info(slot_dir), error = function(e) NULL) -etag_by_file <- if (is.null(info) || nrow(info) == 0L) { - cat(sprintf( - "WARNING: could not list %s; building the whole shard\n", - slot_dir - )) - stats::setNames(character(), character()) -} else { - stats::setNames( - gsub('^"|"$', "", as.character(info$etag)), - basename(as.character(info$uri)) - ) -} - -cran <- tryCatch( - { - con <- gzcon(url( - "https://cloud.r-project.org/src/contrib/PACKAGES.gz", - open = "rb" - )) - on.exit(close(con), add = TRUE) - read.dcf(con, fields = c("Package", "Version", "MD5sum")) - }, - error = function(e) { - cat(sprintf( - "WARNING: could not read CRAN's index (%s)\n", - conditionMessage(e) - )) - NULL - } -) -cran_version <- stats::setNames(character(), character()) -cran_md5 <- stats::setNames(character(), character()) -if (!is.null(cran)) { - cran_version <- stats::setNames( - as.character(cran[, "Version"]), - as.character(cran[, "Package"]) - ) - keep <- !is.na(cran[, "MD5sum"]) - cran_md5 <- stats::setNames( - as.character(cran[keep, "MD5sum"]), - paste(cran[keep, "Package"], cran[keep, "Version"], sep = "_") - ) -} - -before <- length(mine) -mine <- outstanding_packages(mine, etag_by_file, cran_version, cran_md5) -cat(sprintf( - "Resume: %s of %s already carry a binary; %s outstanding\n", - before - length(mine), - before, - length(mine) -)) - -if (length(mine) == 0L) { - cat("Nothing outstanding for this shard\n") - q("no") -} - -### Build - -options( - crayon.enabled = TRUE, - Ncpus = as.integer(Sys.getenv("NCPUS", "2")), - future.globals.onReference = NULL -) - -started <- Sys.time() -n <- length(mine) -completed <- 0L -for (i in seq_along(mine)) { - elapsed <- as.numeric(difftime(Sys.time(), started, units = "hours")) - if (elapsed > args$budget_hours) { - cat(sprintf( - "Budget of %sh reached after %d/%d packages; stopping cleanly. The next run resumes from the bucket.\n", - args$budget_hours, - completed, - n - )) - break - } - - x <- mine[i] - cat(sprintf("[%d/%d] %s\n", i, n, x)) - tryCatch( - bincraft::build_binary_package( - x, - tag_limit = 1L, - patches = "local/patches", - s3_endpoint = "https://s3.eu-central-003.backblazeb2.com", - s3_region = "eu-central-003", - s3_bucket = "devxy-rpkgs-binaries", - s3_access_key_id = Sys.getenv("B2_S3_ACCESS_KEY"), - s3_secret_access_key = Sys.getenv("B2_S3_SECRET_KEY"), - metadata_db_host = "r-binaries.devxy.io", - metadata_db_name = "build_metadata", - metadata_db_table = "single_builds", - metadata_db_user = "rpkgs", - metadata_db_password = Sys.getenv("PGPASS"), - metadata_db_sslmode = "require", - metadata_db_port = 15432, - archive = TRUE, - upload = TRUE, - store_build_metadata = TRUE - ), - error = function(e) { - cat(sprintf("ERROR building %s - %s\n", x, conditionMessage(e))) - } - ) - completed <- completed + 1L -} - -cat(sprintf( - "Shard %s/%s finished: %d/%d packages processed in %.1fh\n", - args$split_index, - args$split_into, - completed, - n, - as.numeric(difftime(Sys.time(), started, units = "hours")) -)) diff --git a/local/repair-built-stamp.R b/local/repair-built-stamp.R deleted file mode 100644 index 06f73be..0000000 --- a/local/repair-built-stamp.R +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env Rscript - -### Rewrite a broken `Built` stamp across one arch/codename slot. -### -### A slot's index can end up advertising a stamp whose platform triple is -### unusable, e.g. `Built: R 4.5.0; NA; ...`. uvr picks binary vs source by -### matching that triple plus the R minor, so no client matches it and the whole -### slot silently reverts to source-only, which makes uvr compile everything and -### fail wherever a system `-dev` library is missing. -### -### Rewriting it is not a matter of re-running the normal index update. -### `upload_package_index()` reuses the slot's remote `PACKAGES.db`, and -### cranlike's `update_db()` only reparses files whose md5 changed, so entries -### already in the database keep the stamp they were written with. Dropping -### `PACKAGES.db` to force a full reparse does work, but for an S3 repo cranlike -### reads each package's metadata from the CRAN *source* mirror on GitHub, so a -### 25k-entry slot means 25k requests to raw.githubusercontent.com and a real -### risk of being rate-limited part-way through. -### -### Only the `Built` column is wrong, so correct it in place instead: patch the -### column in `PACKAGES.db`, put the database back, and let -### `upload_package_index()` re-emit `PACKAGES*` from it. `update_db()` always -### rewrites the index files even when nothing was reparsed, so no tarball is -### re-read and nothing is fetched from GitHub. -### -### The replacement comes from `bincraft::built_stamp()` under the R running -### this script, so run it under the R version the slot should advertise (the -### `R_VERSION` its entry in `.crow/process-updates.yaml` uses). That is what a -### healthy `upload_package_index()` run would have written. -### -### Usage, inside the platform's build image: -### Rscript local/repair-built-stamp.R [--apply] -### -### Without `--apply` it reports what it would change and touches nothing. - -suppressPackageStartupMessages({ - library(bincraft) -}) - -args <- commandArgs(trailingOnly = TRUE) -arch <- args[1L] -apply_changes <- "--apply" %in% args - -if (is.na(arch) || !nzchar(arch)) { - stop( - "Usage: Rscript local/repair-built-stamp.R [--apply]", - call. = FALSE - ) -} - -bucket <- "devxy-rpkgs-binaries" -endpoint <- "https://s3.eu-central-003.backblazeb2.com" -region <- "eu-central-003" - -codename <- bincraft::set_codename(NULL) -if (is.null(codename) || is.na(codename) || !nzchar(codename)) { - stop( - "Could not detect a codename from /etc/os-release; run this in a build image.", - call. = FALSE - ) -} - -# built_stamp() refuses an unusable platform, so a broken build image fails here -# rather than writing a second bad stamp over the first one. -stamp <- bincraft::built_stamp() -message(sprintf("Slot: %s/%s", arch, codename)) -message(sprintf("New stamp: %s", stamp)) - -s3fs::s3_file_system( - aws_access_key_id = Sys.getenv("B2_S3_ACCESS_KEY"), - aws_secret_access_key = Sys.getenv("B2_S3_SECRET_KEY"), - endpoint = endpoint, - region_name = region, - refresh = TRUE -) - -base_dir <- file.path(bucket, arch, codename, "latest", "src", "contrib") - -# A stamp is broken when its platform component is absent or literally "NA". -broken_stamp_where <- paste( - "Built IS NULL", - "OR Built LIKE '%; NA;%'", - "OR Built LIKE '%; ;%'" -) - -# The generic slot plus every per-minor sub-slot, which carry the same stamp and -# are poisoned by the same run. -r_minors <- sub( - "^.*/R/([0-9]+\\.[0-9]+)\\.[0-9]+$", - "\\1", - list.dirs("/opt/R", recursive = FALSE) -) -r_minors <- unique(grep("^[0-9]+\\.[0-9]+$", r_minors, value = TRUE)) -slots <- c(base_dir, file.path(base_dir, r_minors)) - -repair_slot <- function(slot) { - db_remote <- file.path(slot, "PACKAGES.db") - if (!s3fs::s3_file_exists(db_remote)) { - message(sprintf(" %s: no PACKAGES.db, skipping", slot)) - return(invisible(NULL)) - } - - db_local <- tempfile(fileext = ".db") - s3fs::s3_file_download(db_remote, db_local, overwrite = TRUE) - - con <- DBI::dbConnect(RSQLite::SQLite(), db_local) - on.exit(DBI::dbDisconnect(con), add = TRUE) - - total <- DBI::dbGetQuery(con, "SELECT COUNT(*) AS n FROM packages")$n - broken <- DBI::dbGetQuery( - con, - sprintf("SELECT COUNT(*) AS n FROM packages WHERE %s", broken_stamp_where) - )$n - - message(sprintf(" %s: %s entries, %s broken", slot, total, broken)) - if (broken == 0L) { - return(invisible(NULL)) - } - if (!apply_changes) { - message(" (dry run, pass --apply to rewrite)") - return(invisible(NULL)) - } - - DBI::dbExecute( - con, - sprintf("UPDATE packages SET Built = ? WHERE %s", broken_stamp_where), - params = list(stamp) - ) - DBI::dbDisconnect(con) - on.exit() - - s3fs::s3_file_upload(db_local, db_remote, overwrite = TRUE) - message(sprintf(" rewrote %s entries and uploaded PACKAGES.db", broken)) - invisible(NULL) -} - -invisible(lapply(slots, repair_slot)) - -if (!apply_changes) { - message("Dry run complete; nothing was changed.") - quit(save = "no") -} - -# Re-emit PACKAGES/PACKAGES.gz/PACKAGES.rds from the corrected database. Nothing -# is reparsed, because no tarball's md5 changed. -message("Re-emitting index files from the corrected database...") -bincraft::upload_package_index( - codename = codename, - s3_endpoint = endpoint, - s3_region = region, - s3_bucket = bucket, - s3_access_key_id = Sys.getenv("B2_S3_ACCESS_KEY"), - s3_secret_access_key = Sys.getenv("B2_S3_SECRET_KEY") -) -for (minor in r_minors) { - try( - bincraft::upload_package_index( - codename = codename, - r_minor = minor, - s3_endpoint = endpoint, - s3_region = region, - s3_bucket = bucket, - s3_access_key_id = Sys.getenv("B2_S3_ACCESS_KEY"), - s3_secret_access_key = Sys.getenv("B2_S3_SECRET_KEY") - ), - silent = FALSE - ) -} - -message("Done. Verify with:") -message(sprintf( - " curl -sS https://cran.devxy.io/%s/%s/latest/src/contrib/PACKAGES | grep '^Built:' | sort | uniq -c", - arch, - codename -)) diff --git a/local/retire-slot.R b/local/retire-slot.R deleted file mode 100644 index 383fd2c..0000000 --- a/local/retire-slot.R +++ /dev/null @@ -1,240 +0,0 @@ -### Retire a slot: strip it from the pipelines and delete its objects from S3. -# -# Rscript local/retire-slot.R [--apply] [--config-only|--assets-only] -# -# Retiring a distro retires both architectures, because `local.rpkgs_slots` in -# `cdn.tf` is a setproduct over them. -# -# Order: remove the slot from `local.rpkgs_slots` (and the website's -# `DistroId`) and apply that first, then run this. Deleting objects while a -# pipeline still targets the slot just means the next nightly rebuilds them. -# -# Cron jobs live on the Crow server, not here, and are printed at the end. -# -# Only structural YAML is edited. Prose mentioning the slot is left alone, so -# grep for the name afterwards. -# -# Dry run by default. - -options(error = function() { - cat("ERROR:", geterrmessage(), "\n", file = stdout()) - q(status = 1) -}) - -args <- commandArgs(trailingOnly = TRUE) -apply_changes <- "--apply" %in% args -config_only <- "--config-only" %in% args -assets_only <- "--assets-only" %in% args -args <- args[!args %in% c("--apply", "--config-only", "--assets-only")] -if (length(args) != 1L || (config_only && assets_only)) { - stop( - "usage: retire-slot.R [--apply] [--config-only|--assets-only]", - call. = FALSE - ) -} -codename <- args[[1L]] - -# The bucket and `CODENAME:` say `alpine322`; `OS:` and the options say -# `alpine-322`. Both have to go. -pipeline_os <- if (grepl("^rhel[0-9]+$", codename)) { - sub("^rhel", "redhat-", codename) -} else if (grepl("^alpine[0-9]+$", codename)) { - sub("^alpine", "alpine-", codename) -} else { - # Single bracket so an unknown codename yields NA, not an error. - c(resolute = "ubuntu-2604", noble = "ubuntu-2404", jammy = "ubuntu-2204")[ - codename - ] -} -if (is.na(pipeline_os) || length(pipeline_os) == 0L) { - stop(sprintf("Unrecognised codename '%s'.", codename), call. = FALSE) -} - -# The `OS_VERSION` dropdowns are keyed on the image tag, not the slot name. -image_tag <- if (grepl("^alpine-", pipeline_os)) { - sub("^alpine-([0-9])([0-9]+)$", "\\1.\\2", pipeline_os) -} else if (grepl("^redhat-", pipeline_os)) { - sub("^redhat-", "", pipeline_os) -} else { - codename -} - -# Refuse a slot that is still routed: deleting its objects would leave the edge -# serving indexes that name files which no longer exist. -tf <- readLines("cdn.tf", warn = FALSE) -start <- grep("rpkgs_slots\\s*=", tf) -if (length(start) != 1L) { - stop( - "Could not find exactly one `rpkgs_slots` local in cdn.tf.", - call. = FALSE - ) -} -block <- paste(tf[start:min(start + 10L, length(tf))], collapse = "\n") -if (grepl(sprintf('"%s"', codename), block, fixed = TRUE)) { - stop( - sprintf( - "'%s' is still listed in `local.rpkgs_slots` in cdn.tf, so the edge still routes it. Remove it there (and from the website's DistroId union) and apply the OpenTofu change first.", # nolint - codename - ), - call. = FALSE - ) -} - -cat(sprintf( - "Retiring %s (pipelines call it '%s', images tag it '%s')\n\n", - codename, - pipeline_os, - image_tag -)) - -## 1. Pipeline definitions ---------------------------------------------------- - -# Line-based on purpose: a YAML round trip would reflow all 18 files. -strip_slot <- function(lines) { - keep <- rep(TRUE, length(lines)) - i <- 1L - while (i <= length(lines)) { - line <- lines[[i]] - row <- regexec( - sprintf( - "^(\\s*)-\\s*(OS|CODENAME):\\s*(%s|%s)\\s*$", - pipeline_os, - codename - ), - line - ) - m <- regmatches(line, row)[[1L]] - if (length(m) == 4L) { - indent <- nchar(m[[2L]]) - keep[[i]] <- FALSE - j <- i + 1L - while (j <= length(lines)) { - nxt <- lines[[j]] - if ( - !nzchar(trimws(nxt)) || nchar(sub("^(\\s*).*$", "\\1", nxt)) <= indent - ) { - break - } - keep[[j]] <- FALSE - j <- j + 1L - } - i <- j - next - } - if (grepl(sprintf("^\\s*-\\s*%s-(amd64|arm64)\\s*$", pipeline_os), line)) { - keep[[i]] <- FALSE - } - if (grepl(sprintf("^\\s*-\\s*'%s'\\s*$", image_tag), line)) { - keep[[i]] <- FALSE - } - i <- i + 1L - } - out <- lines[keep] - # A dropdown cannot default to an option that no longer exists. - bad_default <- grepl(sprintf("^\\s*default:\\s*'%s'\\s*$", image_tag), out) - if (any(bad_default)) { - for (k in which(bad_default)) { - opts <- grep("^\\s*-\\s*'[^']+'\\s*$", out[seq_len(k - 1L)]) - if (length(opts) == 0L) { - next - } - # Walk back to this block's first option, not the nearest one. - first <- max(opts) - while (first > 1L && grepl("^\\s*-\\s*'[^']+'\\s*$", out[[first - 1L]])) { - first <- first - 1L - } - replacement <- sub("^\\s*-\\s*'([^']+)'\\s*$", "\\1", out[[first]]) - out[[k]] <- sub("'[^']+'", sprintf("'%s'", replacement), out[[k]]) - cat(sprintf(" default moved to '%s'\n", replacement)) - } - } - out -} - -if (!assets_only) { - files <- sort(list.files(".crow", pattern = "\\.yaml$", full.names = TRUE)) - touched <- 0L - for (f in files) { - lines <- readLines(f, warn = FALSE) - out <- strip_slot(lines) - if (length(out) == length(lines)) { - next - } - touched <- touched + 1L - cat(sprintf( - " %s %-45s -%d lines\n", - if (apply_changes) "EDIT " else "would edit", - f, - length(lines) - length(out) - )) - if (apply_changes) { - writeLines(out, f) - } - } - if (touched == 0L) { - cat(" No pipeline references the slot.\n") - } else if (apply_changes) { - cat(sprintf( - " %d file(s) edited. Verify with `crow lint .crow/` and `Rscript local/check-slots.R`.\n", - touched - )) - } - cat("\n") -} - -## 2. Bucket objects ---------------------------------------------------------- - -if (!config_only) { - library(s3fs, quietly = TRUE) - if (!nzchar(Sys.getenv("B2_S3_ACCESS_KEY"))) { - stop( - "B2_S3_ACCESS_KEY is unset. B2 lists nothing without credentials, which is indistinguishable from an already-clean slot.", # nolint - call. = FALSE - ) - } - bucket <- "devxy-rpkgs-binaries" - s3fs::s3_file_system( - aws_access_key_id = Sys.getenv("B2_S3_ACCESS_KEY"), - aws_secret_access_key = Sys.getenv("B2_S3_SECRET_KEY"), - endpoint = "https://s3.eu-central-003.backblazeb2.com", - region_name = "eu-central-003", - refresh = TRUE - ) - - for (arch in c("amd64", "arm64")) { - prefix <- sprintf("%s/%s/%s", bucket, arch, codename) - if (!s3fs::s3_dir_exists(prefix)) { - cat(sprintf(" %s/%s: nothing in the bucket.\n", arch, codename)) - next - } - objects <- s3fs::s3_dir_ls(prefix, recurse = TRUE) - cat(sprintf( - " %s %s/%s: %d object(s)\n", - if (apply_changes) "DELETE" else "would delete", - arch, - codename, - length(objects) - )) - if (apply_changes && length(objects) > 0L) { - s3fs::s3_dir_delete(prefix) - cat(sprintf(" deleted s3://%s\n", prefix)) - } - } - cat("\n") -} - -## 3. Crons ------------------------------------------------------------------ - -if (!apply_changes) { - cat("Dry run: nothing was edited or deleted. Re-run with --apply.\n\n") -} - -cat("Crons still to remove on the Crow server (they are not in this repo):\n") -cat(sprintf( - " crow-at ci.devxy.io repo cron ls devxy/build-cran-binaries | grep %s\n", - pipeline_os -)) -cat(" # `crow repo cron rm` 404s here; the API base is /api/v1\n") -cat(" eval \"$(crow-at --export ci.devxy.io)\"\n") -cat(" curl -s -X DELETE -H \"Authorization: Bearer $CROW_TOKEN\" \\\n") -cat(" https://ci.devxy.io/api/v1/repos/5/cron/\n") diff --git a/local/test-package-loading.R b/local/test-package-loading.R index a3b7a28..26a2e82 100644 --- a/local/test-package-loading.R +++ b/local/test-package-loading.R @@ -1,28 +1,22 @@ -# Installs every CRAN package one by one and checks that it loads. Dependencies -# go through uvr via local/uvr-install.sh, which bootstraps the uvr binary on -# first use and installs into .libPaths()[1]. -uvr_install <- function(pkg) { - Sys.setenv( - UVR_R_BIN = file.path(R.home("bin"), "R"), - UVR_TARGET_LIB = .libPaths()[1L] +install.packages( + "pak", + repos = sprintf( + "https://r-lib.github.io/p/pak/stable/%s/%s/%s", + .Platform$pkgType, + R.Version()$os, + R.Version()$arch ) - status <- system2("local/uvr-install.sh", shQuote(pkg)) - if (!identical(status, 0L)) { - stop( - sprintf("uvr failed to install %s (exit %s)", pkg, status), - call. = FALSE - ) - } -} +) +Sys.setenv(PKG_SYSREQS = TRUE) all_pkgs <- rownames(available.packages()) -to_skip <- c("ABRSQOL", "ACA", "ACE.CoCo") -all_pkgs <- setdiff(all_pkgs, to_skip) +to_skip = c("ABRSQOL", "ACA", "ACE.CoCo") +all_pkgs = setdiff(all_pkgs, to_skip) for (i in all_pkgs) { message(sprintf("\nInstalling %s", i)) - uvr_install(i) + pak::pkg_install(i) library(i, character.only = TRUE) } @@ -329,7 +323,7 @@ if (length(to_process) == 0) { } else { for (i in to_process) { message(sprintf("\nInstalling %s", i)) - uvr_install(i) + pak::pkg_install(i) library(i, character.only = TRUE) } # Update to_skip to include all up to the last processed diff --git a/local/tests/test-failing-builds-classify.R b/local/tests/test-failing-builds-classify.R index d4df9f5..66cd26b 100644 --- a/local/tests/test-failing-builds-classify.R +++ b/local/tests/test-failing-builds-classify.R @@ -85,81 +85,3 @@ test_that("propose_registry_entry fills a schema-valid entry for a known lever", # No template -> no proposal (unclassified path). expect_null(propose_registry_entry(classify_error("weird"), "x", "alpine")) }) - -test_that("RcppParallel dependents are blocked, not proposed a per-package patch", { - # Hundreds of packages fail on ubuntu-2604 only because their RcppParallel - # dependency fails to build, so they carry RcppParallel's TBB error text. - failures <- data.frame( - name = c("ACEsimFit", "AovBay", "AdaptGauss"), - platform = "ubuntu-2604", - arch = "amd64", - error_text = "Error: USE_TBB=Linux is not supported on this toolchain", - stringsAsFactors = FALSE - ) - report <- build_triage_report(failures, registered_pkgs = character(0L)) - grp <- Filter(function(g) g$signature == "rcppparallel-bundled-tbb", report)[[ - 1L - ]] - # No bogus per-dependent entries (the patch targets RcppParallel's source). - expect_null(grp$proposed_entries) - expect_identical(grp$blocked_on, "RcppParallel") -}) - -test_that("failing_dependency names the dependency that actually failed", { - # A leaf package (BFpack) that fails building its rstan dependency. - txt <- paste( - "Error in installing dependencies for package BFpack with tag 1.6.1", - "Failed to build source package rstan.", - "ERROR: compilation failed for package ‘rstan’", - sep = "\n" - ) - expect_identical(failing_dependency(txt, "BFpack"), "rstan") - # A package failing in its OWN compilation is not a cascade. - own <- "ERROR: compilation failed for package ‘BFpack’" - expect_true(is.na(failing_dependency(own, "BFpack"))) - expect_true(is.na(failing_dependency(NA_character_, "x"))) - expect_true(is.na(failing_dependency("some unrelated error", "x"))) -}) - -test_that("Stan packages blocked on rstan are not proposed a per-package entry", { - # BFpack/GMLTM fail building rstan; the tbb error is in rstan's compile. - cascade <- paste( - "Failed to build source package rstan.", - "fatal error: tbb/tbb_stddef.h: No such file or directory", - sep = "\n" - ) - failures <- data.frame( - name = c("BFpack", "GMLTM", "someOwnPkg"), - platform = "ubuntu-2604", - arch = "amd64", - error_text = c( - cascade, - cascade, - # someOwnPkg fails in its OWN compile on the same header -> fixable. - "someOwnPkg.cpp: fatal error: tbb/tbb_stddef.h: No such file or directory" - ), - stringsAsFactors = FALSE - ) - report <- build_triage_report(failures, registered_pkgs = character(0L)) - grp <- Filter(function(g) g$signature == "tbb-stddef-removed", report)[[1L]] - # Only the own-compile package is proposed; the rstan cascades are blocked. - expect_identical(names(grp$proposed_entries), "someOwnPkg") - expect_setequal(grp$blocked_packages, c("BFpack", "GMLTM")) - expect_identical(grp$blocked_on, "rstan") -}) - -test_that("RcppParallel itself is still proposed when it is the failing package", { - failures <- data.frame( - name = "RcppParallel", - platform = "ubuntu-2604", - arch = "amd64", - error_text = "Error: USE_TBB=Linux is not supported on this toolchain", - stringsAsFactors = FALSE - ) - report <- build_triage_report(failures, registered_pkgs = character(0L)) - grp <- report[[1L]] - expect_identical(grp$signature, "rcppparallel-bundled-tbb") - expect_false(is.null(grp$proposed_entries)) - expect_true("RcppParallel" %in% names(grp$proposed_entries)) - expect_null(grp$blocked_on) # RcppParallel is the target, not a dependent -}) diff --git a/local/tests/test-newer-r-helpers.R b/local/tests/test-newer-r-helpers.R deleted file mode 100644 index c202b7f..0000000 --- a/local/tests/test-newer-r-helpers.R +++ /dev/null @@ -1,81 +0,0 @@ -source(file.path("..", "newer-r-helpers.R")) - -test_that("R constraints respect patch versions, bounds, and missing fields", { - expect_true(r_requirement_satisfied(NA_character_, "4.4.3")) - expect_true(r_requirement_satisfied( - "Matrix (>= 1.4-0), R (>= 4.5.0)", - "4.5.3" - )) - expect_false(r_requirement_satisfied("R (>= 4.5.0)", "4.4.3")) - expect_false(r_requirement_satisfied("R (>= 4.5.4)", "4.5.3")) - expect_true(r_requirement_satisfied("R (>= 4.5), R (< 4.6)", "4.5.3")) - expect_false(r_requirement_satisfied("R (>= 4.5), R (< 4.6)", "4.6.0")) - expect_true(r_requirement_satisfied("R (== 4.5.3)", "4.5.3")) - expect_true(r_requirement_satisfied("R (> 4.5.2), R (<= 4.5.3)", "4.5.3")) - expect_true(r_requirement_satisfied("R, Rcpp (>= 1.0)", "4.4.3")) - expect_error(r_requirement_satisfied("R (~ 4.5)", "4.5.3"), "Cannot parse") -}) - -fixture <- function() { - data.frame( - Package = c( - "arules", - "arulesSequences", - "indirect", - "optional", - "ordinary", - "future", - "blocked" - ), - Version = rep("1.0", 7L), - Depends = c( - "R (>= 4.5.0)", - "arules", - NA, - NA, - "R (>= 4.0)", - "R (>= 4.7)", - NA - ), - Imports = c(NA, NA, "arulesSequences", NA, NA, NA, "arules, future"), - LinkingTo = NA_character_, - Suggests = c(NA, NA, NA, "arules", NA, NA, NA) - ) -} - -test_that("every compatible newer minor includes required dependents only", { - db <- fixture() - for (target in c("4.5.3", "4.6.0")) { - expect_identical( - newer_r_candidates(db, "4.4.3", target)$Package, - c("arules", "arulesSequences", "indirect") - ) - } - expect_equal(nrow(newer_r_candidates(db, "4.4.3", "4.4.3")), 0L) - expect_equal(nrow(newer_r_candidates(db, "4.5.3", "4.4.3")), 0L) - expect_equal(nrow(newer_r_candidates(db, "4.5.3", "4.6.0")), 0L) -}) - -test_that("LinkingTo, dependency cycles, Windows and empty metadata are handled", { - db <- fixture() - db$LinkingTo[5L] <- "arules" - db$Imports[1L] <- "ordinary" - db$OS_type <- c(NA, "windows", rep(NA_character_, 5L)) - expect_identical( - newer_r_candidates(db, "4.4.3", "4.6.0")$Package, - c("arules", "indirect", "ordinary") - ) - expect_equal(nrow(newer_r_candidates(db[FALSE, ], "4.4.3", "4.6.0")), 0L) -}) - -test_that("R-devel Recommended copies cannot block ordinary CRAN dependencies", { - db <- fixture() - db$Path <- NA_character_ - future_copy <- db[1L, ] - future_copy$Depends <- "R (>= 4.7)" - future_copy$Path <- "4.7.0/Recommended" - expect_identical( - newer_r_candidates(rbind(future_copy, db), "4.4.3", "4.6.1")$Package, - c("arules", "arulesSequences", "indirect") - ) -}) diff --git a/local/tests/test-proposal-tracking-lib.R b/local/tests/test-proposal-tracking-lib.R index 869d2b3..55da4b6 100644 --- a/local/tests/test-proposal-tracking-lib.R +++ b/local/tests/test-proposal-tracking-lib.R @@ -46,38 +46,6 @@ test_that("merge_ledger handles an empty/NULL starting ledger", { expect_length(merge_ledger(list(), new), 1L) }) -test_that("dedupe_candidates keeps single-signature pkgs, routes conflicts to triage", { - candidates <- list( - list(package = "StanHeaders", signature = "tbb-stddef-removed"), - list(package = "hmmTMB", signature = "tbb-stddef-removed"), - list(package = "hmmTMB", signature = "rcppparallel-bundled-tbb") # conflict - ) - out <- dedupe_candidates(candidates) - kept <- vapply(out$keep, function(c) c$package, character(1L)) - expect_identical(sort(kept), "StanHeaders") # hmmTMB dropped as ambiguous - expect_true("hmmTMB" %in% names(out$ambiguous)) - expect_setequal( - out$ambiguous$hmmTMB, - c("tbb-stddef-removed", "rcppparallel-bundled-tbb") - ) -}) - -test_that("dedupe_candidates collapses a package repeated under one signature", { - candidates <- list( - list(package = "rstan", signature = "tbb-stddef-removed"), - list(package = "rstan", signature = "tbb-stddef-removed") - ) - out <- dedupe_candidates(candidates) - expect_length(out$keep, 1L) - expect_length(out$ambiguous, 0L) -}) - -test_that("dedupe_candidates handles the empty list", { - out <- dedupe_candidates(list()) - expect_length(out$keep, 0L) - expect_length(out$ambiguous, 0L) -}) - test_that("signature_hit_rate splits addressed vs open per signature", { report <- build_triage_report(mk_failures(), registered_pkgs = "RcppParallel") hit <- signature_hit_rate(report, registered_pkgs = "RcppParallel") @@ -119,115 +87,6 @@ test_that("proposed_vs_merged marks a package merged once it is registered", { expect_identical(stan$status, "merged") }) -test_that("unclassified_summary ranks unknown groups and caps output", { - failures <- data.frame( - name = c("a", "b", "c", "d", "solo"), - platform = "ubuntu-2604", - arch = "amd64", - error_text = c( - # 4 builds share one unknown fingerprint; 1 build a different unknown. - rep("mystery linker meltdown at stage 3", 4L), - "a totally different unknown boom" - ), - stringsAsFactors = FALSE - ) - report <- build_triage_report(failures, registered_pkgs = character(0L)) - s <- unclassified_summary(report, max_groups = 30L, max_pkgs = 2L) - expect_identical(s$total_groups, 2L) - expect_identical(s$total_builds, 5L) - # Largest group first, and its example packages are capped at max_pkgs. - expect_identical(s$groups[[1L]]$build_count, 4L) - expect_length(s$groups[[1L]]$packages, 2L) - expect_true(s$groups[[1L]]$packages_truncated) - - # max_groups cap is reported, not silently dropped. - capped <- unclassified_summary(report, max_groups = 1L) - expect_length(capped$groups, 1L) - expect_identical(capped$dropped_groups, 1L) -}) - -test_that("blocked_by_dependency aggregates across groups, deduped and ranked", { - # RcppParallel dependents split across platforms/fingerprints -> separate - # groups, but one aggregated line; a dependent seen twice is counted once. - failures <- data.frame( - name = c("ACEsimFit", "AovBay", "AdaptGauss", "ACEsimFit", "loner"), - platform = c( - "ubuntu-2604", - "ubuntu-2604", - "alpine-324", - "alpine-324", - "ubuntu-2604" - ), - arch = "amd64", - error_text = c( - rep("Error: USE_TBB=Linux is not supported on this toolchain", 4L), - "Failed to build source package rstan.\nfatal error: tbb/tbb_stddef.h" - ), - stringsAsFactors = FALSE - ) - report <- build_triage_report(failures, registered_pkgs = character(0L)) - agg <- blocked_by_dependency(report, max_pkgs = 2L) - deps <- vapply(agg, function(b) b$dependency, character(1L)) - expect_true("RcppParallel" %in% deps && "rstan" %in% deps) - rcpp <- Filter(function(b) b$dependency == "RcppParallel", agg)[[1L]] - # ACEsimFit appears in two groups -> counted once (3 distinct dependents). - expect_identical(rcpp$n_packages, 3L) - expect_true(rcpp$packages_truncated) # capped at max_pkgs = 2 - # Ranked by dependent count: RcppParallel (3) before rstan (1). - expect_identical(deps[[1L]], "RcppParallel") -}) - -test_that("entry_applies_to_os matches codename, family, and wildcard", { - expect_true(entry_applies_to_os(list("ubuntu-2604"), "ubuntu-2604")) - expect_true(entry_applies_to_os(list("ubuntu"), "ubuntu-2604")) # family - expect_true(entry_applies_to_os(list("*"), "ubuntu-2604")) - expect_true(entry_applies_to_os(list("alpine", "ubuntu-2604"), "ubuntu-2604")) - expect_false(entry_applies_to_os(list("alpine-324"), "ubuntu-2604")) - expect_false(entry_applies_to_os(list("ubuntu-2404"), "ubuntu-2604")) # other codename -}) - -test_that("new_registry_packages returns only added entries for the platform", { - base <- list( - list( - package = "RcppParallel", - platforms = list("alpine", "ubuntu-2604"), - versions = "*" - ) - ) - current <- list( - base[[1L]], # unchanged -> not "new" - list(package = "BFpack", platforms = list("ubuntu-2604"), versions = "*"), - list( - package = "someAlpinePkg", - platforms = list("alpine-324"), - versions = "*" - ) - ) - # For ubuntu-2604: only the newly-added BFpack (RcppParallel is unchanged, - # someAlpinePkg does not apply to this OS). - expect_identical( - new_registry_packages(current, base, os = "ubuntu-2604"), - "BFpack" - ) - # For alpine-324: the alpine package is new and applies. - expect_identical( - new_registry_packages(current, base, os = "alpine-324"), - "someAlpinePkg" - ) - # Without an OS filter, both additions are returned. - expect_setequal( - new_registry_packages(current, base), - c("BFpack", "someAlpinePkg") - ) - # A changed platform set on the same package counts as a new entry. - widened <- list(list( - package = "RcppParallel", - platforms = list("*"), - versions = "*" - )) - expect_identical(new_registry_packages(widened, base), "RcppParallel") -}) - test_that("retirement_candidates flags entries whose package no longer fails", { entries <- list( list(package = "RcppParallel"), diff --git a/local/tests/test-rebuild-missing.R b/local/tests/test-rebuild-missing.R deleted file mode 100644 index a5e70b9..0000000 --- a/local/tests/test-rebuild-missing.R +++ /dev/null @@ -1,94 +0,0 @@ -source(file.path("..", "rebuild-missing-helpers.R")) - -test_that("shard_slice partitions the list without gaps or overlap", { - pkgs <- letters[1:10] - parts <- lapply(1:3, function(i) shard_slice(pkgs, 3, i)) - - expect_identical(parts[[1]], c("a", "d", "g", "j")) - expect_identical(parts[[2]], c("b", "e", "h")) - expect_identical(parts[[3]], c("c", "f", "i")) - - expect_identical(sort(unlist(parts)), sort(pkgs)) - expect_identical(anyDuplicated(unlist(parts)), 0L) -}) - -test_that("shard_slice is deterministic and survives short lists", { - expect_identical( - shard_slice(letters[1:10], 3, 2), - shard_slice(letters[1:10], 3, 2) - ) - expect_identical(shard_slice(character(0), 3, 1), character(0)) - # more shards than packages: the tail shards get nothing rather than erroring - expect_identical(shard_slice(c("a"), 3, 1), "a") - expect_identical(shard_slice(c("a"), 3, 2), character(0)) -}) - -test_that("shard_slice rejects an out-of-range index", { - expect_error(shard_slice(letters, 3, 4), "split_index") - expect_error(shard_slice(letters, 3, 0), "split_index") -}) - -test_that("outstanding_packages keeps source fallbacks and drops real binaries", { - cran_version <- c(httr = "1.4.8", R6 = "2.6.1", curl = "7.1.0") - cran_md5 <- c( - httr_1.4.8 = "8756015b94a9cff6f410ca4de8557f12", - R6_2.6.1 = "f01b1787f12797c29194d63c9afd5d70", - curl_7.1.0 = "8af2ccbf5d85dc18866f45f1f26f348d" - ) - etag <- c( - # byte-identical to CRAN: the build never happened - "httr_1.4.8.tar.gz" = "8756015b94a9cff6f410ca4de8557f12", - # a real binary was published - "R6_2.6.1.tar.gz" = "9d6087ee9adda3f0a3b8067cfc652c05" - # curl has no object at all - ) - - out <- outstanding_packages( - c("httr", "R6", "curl"), - etag, - cran_version, - cran_md5 - ) - expect_identical(out, c("httr", "curl")) -}) - -test_that("outstanding_packages treats unknowns as already built", { - cran_version <- c(a = "1.0", b = "1.0") - cran_md5 <- c(a_1.0 = "aaaa") - - # a multipart ETag carries no MD5, and `b` is missing from CRAN's index: - # neither may schedule a rebuild - etag <- c("a_1.0.tar.gz" = "abc-3", "b_1.0.tar.gz" = "bbbb") - - expect_identical( - outstanding_packages(c("a", "b"), etag, cran_version, cran_md5), - character(0) - ) -}) - -test_that("outstanding_packages keeps a package CRAN has no version for", { - out <- outstanding_packages( - "ghost", - c(), - c(other = "1.0"), - c(other_1.0 = "aaaa") - ) - expect_identical(out, "ghost") -}) - -test_that("outstanding_packages handles an empty list", { - expect_identical( - outstanding_packages(character(0), c(), c(), c()), - character(0) - ) -}) - -test_that("parse_rebuild_args defaults the budget", { - a <- parse_rebuild_args(c("3", "2")) - expect_identical(a$split_into, 3L) - expect_identical(a$split_index, 2L) - expect_identical(a$budget_hours, 20) - - b <- parse_rebuild_args(c("3", "2", "1.5")) - expect_identical(b$budget_hours, 1.5) -}) diff --git a/local/tests/test-trim-pkgcache.R b/local/tests/test-trim-pkgcache.R new file mode 100644 index 0000000..1a5b418 --- /dev/null +++ b/local/tests/test-trim-pkgcache.R @@ -0,0 +1,68 @@ +source(file.path("..", "r-minor-helpers.R")) + +# Build a fake _metadata dir under a temp R_PKG_CACHE_DIR. Each entry's mtime is +# set to `age_secs` in the past so we can exercise the age gate deterministically. +make_meta <- function(patched = 0L, pkgs = 0L, keep_repos = TRUE, age_secs = 3600) { + root <- tempfile("pkgcache-") + meta <- file.path(root, "R", "pkgcache", "_metadata") + dir.create(meta, recursive = TRUE) + old <- Sys.time() - age_secs + mk_dir <- function(p) { dir.create(p); Sys.setFileTime(p, old); p } + mk_file <- function(p) { writeLines("x", p); Sys.setFileTime(p, old); p } + for (i in seq_len(patched)) mk_dir(file.path(meta, sprintf("patched-%03d", i))) + for (i in seq_len(pkgs)) mk_file(file.path(meta, sprintf("pkgs-%03d.rds", i))) + if (keep_repos) { + mk_dir(file.path(meta, "CRAN-075c426938")) + mk_dir(file.path(meta, "BioCsoft-1ac964ed6c")) + mk_file(file.path(meta, "bioc-sysreqs.dcf.gz")) + mk_dir(file.path(root, "R", "pkgcache", "pkg")) # downloads, must survive + } + root +} + +n_churn <- function(root) { + meta <- file.path(root, "R", "pkgcache", "_metadata") + length(Sys.glob(file.path(meta, "patched-*"))) + + length(Sys.glob(file.path(meta, "pkgs-*.rds"))) +} + +test_that("empty cache_dir is a no-op", { + expect_identical(trim_pkgcache_metadata("", keep = 5L, min_age_secs = 0), 0L) +}) + +test_that("missing _metadata dir is a no-op", { + expect_identical( + trim_pkgcache_metadata(tempfile("absent-"), keep = 5L, min_age_secs = 0), + 0L + ) +}) + +test_that("fewer than keep entries removes nothing", { + root <- make_meta(patched = 2L, pkgs = 2L) + expect_identical(trim_pkgcache_metadata(root, keep = 20L, min_age_secs = 0), 0L) + expect_identical(n_churn(root), 4L) +}) + +test_that("trims down to keep newest, leaving churn == keep", { + root <- make_meta(patched = 30L, pkgs = 30L) # 60 churn entries, all old + removed <- trim_pkgcache_metadata(root, keep = 20L, min_age_secs = 0) + expect_identical(removed, 40L) + expect_identical(n_churn(root), 20L) +}) + +test_that("entries younger than min_age_secs are protected", { + root <- make_meta(patched = 30L, pkgs = 0L, keep_repos = FALSE, age_secs = 60) + # keep=5 would drop 25, but all are 60s old < 600s gate -> nothing removed + expect_identical(trim_pkgcache_metadata(root, keep = 5L, min_age_secs = 600), 0L) + expect_identical(n_churn(root), 30L) +}) + +test_that("stable repo dirs and pkg downloads are never touched", { + root <- make_meta(patched = 30L, pkgs = 30L) + trim_pkgcache_metadata(root, keep = 0L, min_age_secs = 0) + meta <- file.path(root, "R", "pkgcache", "_metadata") + expect_true(dir.exists(file.path(meta, "CRAN-075c426938"))) + expect_true(dir.exists(file.path(meta, "BioCsoft-1ac964ed6c"))) + expect_true(file.exists(file.path(meta, "bioc-sysreqs.dcf.gz"))) + expect_true(dir.exists(file.path(root, "R", "pkgcache", "pkg"))) +}) diff --git a/local/trial-build-patch.R b/local/trial-build-patch.R index 2229074..4e8030a 100644 --- a/local/trial-build-patch.R +++ b/local/trial-build-patch.R @@ -40,26 +40,23 @@ cat(sprintf( patches_dir )) -# build_binary_package() catches build failures internally and RETURNS "error" -# for the failed tag rather than throwing (bincraft >= v4.4.7), so inspect the -# return value -- checking only for a thrown exception reports a broken build as -# passing (false green). -res <- tryCatch( - bincraft::build_binary_package( - package, - tag_limit = 1L, - patches = patches_dir, - archive = FALSE, - upload = FALSE, - store_build_metadata = FALSE - ), +ok <- tryCatch( + { + bincraft::build_binary_package( + package, + tag_limit = 1L, + patches = patches_dir, + archive = FALSE, + upload = FALSE, + store_build_metadata = FALSE + ) + TRUE + }, error = function(e) { - cat(sprintf("Trial build FAILED (threw): %s\n", conditionMessage(e))) - "error" + cat(sprintf("Trial build FAILED: %s\n", conditionMessage(e))) + FALSE } ) -flat <- as.character(unlist(res)) -ok <- length(flat) > 0L && !("error" %in% flat) if (ok) { cat(sprintf("Trial build OK: %s builds with the proposed patch.\n", package)) diff --git a/local/trial-build-registry.R b/local/trial-build-registry.R deleted file mode 100644 index 9708408..0000000 --- a/local/trial-build-registry.R +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env Rscript - -# Merge gate for the auto-patch PR (issue #115, step 3): for every registry -# entry the PR ADDS that applies to this platform, trial-build the package with -# the registry applied, in this platform's own build-env image. Nothing is -# uploaded, archived, or written to the metadata DB. -# -# Exit 0 only if every new entry's package builds; exit 1 if any fails, so it -# gates the PR. A platform with no new entries is a fast no-op. -# -# Usage (inside a build-env image): -# PLATFORM=ubuntu-2604 Rscript local/trial-build-registry.R [base_ref] -# base_ref git ref to diff the registry against (default: origin/main) - -options(error = function() { - cat("ERROR:", geterrmessage(), "\n", file = stdout()) - q(status = 1) -}) - -suppressPackageStartupMessages({ - library(jsonlite, quietly = TRUE) - library(bincraft, quietly = TRUE) -}) - -script_path <- local({ - a <- commandArgs(trailingOnly = FALSE) - f <- sub("^--file=", "", a[grepl("^--file=", a)]) - if (length(f) == 1L && nzchar(f)) normalizePath(f) else NA_character_ -}) -script_dir <- if (is.na(script_path)) "local" else dirname(script_path) -source(file.path(script_dir, "proposal-tracking-lib.R")) - -args <- commandArgs(trailingOnly = TRUE) -base_ref <- if (length(args) >= 1L) { - args[[1L]] -} else { - Sys.getenv("BASE_REF", "origin/main") -} -os <- Sys.getenv("PLATFORM", "") -if (!nzchar(os)) { - stop("PLATFORM env var is not set (e.g. ubuntu-2604).") -} - -patches_dir <- file.path(script_dir, "patches") -registry_file <- file.path(patches_dir, "registry.json") -current <- if (file.exists(registry_file)) { - jsonlite::fromJSON(registry_file, simplifyVector = FALSE) -} else { - list() -} -# Read the registry at base_ref. Fail loud if the ref or file can't be read: -# silently treating the base as empty would trial-build the WHOLE registry -# instead of just the entries the branch adds. -registry_rel <- "local/patches/registry.json" -# system2() with captured output runs via /bin/sh, so shell-quote the git args -# (refs contain "^{}" and ":" that the shell would otherwise mangle). -ref_ok <- suppressWarnings(system2( - "git", - shQuote(c( - "rev-parse", - "--verify", - "--quiet", - sprintf("%s^{commit}", base_ref) - )), - stdout = TRUE, - stderr = FALSE -)) -if (!is.null(attr(ref_ok, "status"))) { - stop(sprintf("base ref %s does not resolve to a commit.", base_ref)) -} -in_base <- suppressWarnings(system2( - "git", - shQuote(c("ls-tree", base_ref, "--", registry_rel)), - stdout = TRUE, - stderr = FALSE -)) -file_in_base <- length(in_base) > 0L && any(nzchar(in_base)) -base_json <- suppressWarnings(system2( - "git", - shQuote(c("show", sprintf("%s:%s", base_ref, registry_rel))), - stdout = TRUE, - stderr = FALSE -)) -show_ok <- is.null(attr(base_json, "status")) -if (file_in_base && !show_ok) { - stop(sprintf( - "could not read %s at %s; refusing to build the whole registry.", - registry_rel, - base_ref - )) -} -base <- if (show_ok && length(base_json) > 0L) { - jsonlite::fromJSON(paste(base_json, collapse = "\n"), simplifyVector = FALSE) -} else { - list() # file genuinely absent at base -> every entry is new -} - -pkgs <- new_registry_packages(current, base, os = os) -if (length(pkgs) == 0L) { - cat(sprintf( - "No new registry entries apply to %s; nothing to trial-build.\n", - os - )) - q(status = 0) -} - -cat(sprintf( - "Trial-building %d new registry %s on %s (vs %s):\n %s\n", - length(pkgs), - if (length(pkgs) == 1L) "entry" else "entries", - os, - base_ref, - toString(pkgs) -)) - -results <- vapply( - pkgs, - function(pkg) { - cat(sprintf("\n=== trial build: %s ===\n", pkg)) - # bincraft::build_binary_package() catches build failures internally and - # RETURNS "error" for the failed tag rather than throwing, so a green gate - # must inspect the return value -- checking only for a thrown exception - # reports a broken build as passing. - res <- tryCatch( - bincraft::build_binary_package( - pkg, - tag_limit = 1L, - patches = patches_dir, - archive = FALSE, - upload = FALSE, - store_build_metadata = FALSE - ), - error = function(e) { - cat(sprintf("FAILED %s (threw): %s\n", pkg, conditionMessage(e))) - "error" - } - ) - flat <- as.character(unlist(res)) - ok <- length(flat) > 0L && !("error" %in% flat) - if (!ok) { - cat(sprintf( - "FAILED %s: build did not succeed (result: %s)\n", - pkg, - if (length(flat) > 0L) toString(flat) else "" - )) - } - ok - }, - logical(1L) -) - -failed <- pkgs[!results] -cat(sprintf( - "\n%d/%d passed on %s.%s\n", - sum(results), - length(results), - os, - if (length(failed) > 0L) sprintf(" Failed: %s", toString(failed)) else "" -)) -if (length(failed) > 0L) { - q(status = 1) -} -q(status = 0) diff --git a/local/uvr-install.sh b/local/uvr-install.sh deleted file mode 100755 index e103e13..0000000 --- a/local/uvr-install.sh +++ /dev/null @@ -1,127 +0,0 @@ -#!/bin/sh -# Install R packages into the CI library with uvr (https://github.com/nbafrank/uvr). -# -# Usage: -# local/uvr-install.sh httr2 jsonlite -# local/uvr-install.sh forgejo::codefloe.com/rpkgs/bincraft@v4.4.3 -# -# Replaces `pak::pak(...)`. uvr is project-scoped: `uvr add` refuses to run -# outside a project and always writes to `.uvr/library/`, and only -# `uvr sync --library` can target an existing library. The project is therefore -# minted in a scratch directory under TMPDIR and thrown away afterwards; that -# also keeps `uvr init`'s `.Rprofile` out of the repo checkout, where it would -# hijack `.libPaths()` for every other R call in the pipeline. -# -# Pruning is a no-op here: uvr disables it whenever `--library` is passed, -# precisely because such a target may be shared (`/mnt/cache/R-pkgs` holds -# bincraft and its dependencies alongside whatever this script installs). -# -# System dependencies come from uvr's vendored r-system-requirements rules, -# with explicit prerequisites below for gaps in Alpine support. -# -# Environment: -# UVR_R_BIN R interpreter to install for; set by install-bincraft.R so -# the per-R-minor passes target their own R, not the primary -# R_VERSION fallback interpreter selector (/opt/R//bin/R) -# UVR_TARGET_LIB target library; defaults to R_LIBS_USER, then to the -# active R's .libPaths()[1] (which is where pak wrote) -# UVR_INSTALL_DIR where the uvr binary lands (default /usr/local/bin) - -set -eu - -# renovate: datasource=github-releases depName=nbafrank/uvr -UVR_PIN="v0.4.7" - -if [ "$#" -eq 0 ]; then - echo "usage: $0 ..." >&2 - exit 2 -fi - -# The build images keep R under /opt/R/ and off PATH. uvr resolves the -# interpreter via PATH and never downloads one unless `uvr r install` is run, so -# put the requested R first. -r_bin="${UVR_R_BIN:-}" -if [ -z "$r_bin" ] && [ -n "${R_VERSION:-}" ] && [ -x "/opt/R/${R_VERSION}/bin/R" ]; then - r_bin="/opt/R/${R_VERSION}/bin/R" -fi -if [ -n "$r_bin" ]; then - PATH="$(dirname "$r_bin"):$PATH" - export PATH -else - r_bin="$(command -v R)" -fi - -target_lib="${UVR_TARGET_LIB:-${R_LIBS_USER:-}}" -if [ -z "$target_lib" ]; then - target_lib="$("$r_bin" --no-echo --no-save -e 'cat(.libPaths()[1])')" -fi -if [ -z "$target_lib" ]; then - echo "error: could not determine a target library; set UVR_TARGET_LIB" >&2 - exit 2 -fi -mkdir -p "$target_lib" - -# Pin the manifest to the active R so the lockfile's R stays in step with the -# library's R sentinel. Without that, uvr can decide the library is ABI-stale -# and wipe it -- and this target is shared with bincraft. uvr only discovers R -# via PATH/R_HOME (it does not scan /opt/R), so the R put on PATH above is the -# only candidate this constraint can resolve to. -# shellcheck disable=SC2016 # $major/$minor are R expressions, not shell vars -r_full="$("$r_bin" --no-echo --no-save -e 'cat(paste(R.version$major, R.version$minor, sep = "."))')" - -install_dir="${UVR_INSTALL_DIR:-/usr/local/bin}" -uvr_bin="${install_dir}/uvr" -if [ ! -x "$uvr_bin" ]; then - echo "Bootstrapping uvr ${UVR_PIN} into ${install_dir}" - UVR_INSTALL_DIR="$install_dir" UVR_VERSION="$UVR_PIN" \ - sh -c 'curl -fsSL https://raw.githubusercontent.com/nbafrank/uvr/main/install.sh | sh' -fi - -project_dir="${TMPDIR:-/tmp}/uvr-ci-$$" -rm -rf "$project_dir" -mkdir -p "$project_dir" -trap 'rm -rf "$project_dir"' EXIT -cd "$project_dir" - -"$uvr_bin" init --here --r-version "$r_full" -# --no-install resolves and locks only; retry because concurrent shards can -# expose short-lived DNS or CRAN-index failures and uvr rolls the manifest back -# cleanly after an unsuccessful resolution. -add_attempt=1 -while ! "$uvr_bin" add --no-install "$@"; do - if [ "$add_attempt" -ge 4 ]; then - echo "error: uvr add failed after ${add_attempt} attempts" >&2 - exit 1 - fi - add_delay=$((add_attempt * 10)) - echo "warning: uvr add attempt ${add_attempt} failed; retrying in ${add_delay}s" >&2 - sleep "$add_delay" - add_attempt=$((add_attempt + 1)) -done - -# TEMPORARY (drop once the images ship a uvr above v0.4.5): the sync below runs -# `apt-get install` for every resolved system dependency without refreshing the -# index first, and the ubuntu build images end their apt layers with -# `rm -rf /var/lib/apt/lists/*`. With no index apt cannot resolve a package that -# exists and is enabled, so `igraph needs: libglpk-dev` fails the whole build -# with `E: Unable to locate package libglpk-dev` on ubuntu 26.04. -# -# Fixed upstream in `e491b2e`, tagged one day after v0.4.5 (nbafrank/uvr#250), -# and the images pick it up via build-env-images#23 — but only after an image -# rebuild is triggered, which is why this runs here too. -# -# apt only: `apk add` fetches its index implicitly and dnf refreshes expired -# metadata on its own. Non-fatal, since a refresh failure still leaves whatever -# index is already there, and the install's own error is the more actionable one. -if command -v apt-get >/dev/null 2>&1; then - apt-get update -qq || echo "warning: apt-get update failed; continuing" >&2 -fi - -# Alpine's build images lack libintl.h, which data.table needs when R enables -# NLS. uvr does not resolve this prerequisite on Alpine, so install it before -# compiling bincraft's dependencies (including when the library cache is cold). -if command -v apk >/dev/null 2>&1; then - apk add --no-cache gettext-dev -fi - -"$uvr_bin" sync --library "$target_lib" --install-system-deps diff --git a/local/verify-repo-routing.R b/local/verify-repo-routing.R deleted file mode 100644 index c19494d..0000000 --- a/local/verify-repo-routing.R +++ /dev/null @@ -1,193 +0,0 @@ -# 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, - # `%.1f` renders NA as the literal "NAs", which reads as a unit rather - # than as a missing measurement. A package that was never in the index - # has no timing at all. - if (is.na(secs)) built else 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) -} diff --git a/local/weekly-missing-binaries-audit.R b/local/weekly-missing-binaries-audit.R index e7ca776..6b32037 100644 --- a/local/weekly-missing-binaries-audit.R +++ b/local/weekly-missing-binaries-audit.R @@ -7,6 +7,7 @@ options(error = function() { suppressPackageStartupMessages(library(data.table)) library(DBI, quietly = TRUE) library(RPostgres, quietly = TRUE) +library(s3fs, quietly = TRUE) library(jsonlite, quietly = TRUE) library(httr2, quietly = TRUE) @@ -49,8 +50,8 @@ cat(sprintf( )) # --------------------------------------------------------------------------- -# 1. Query PostgreSQL for known build failures (before anything that uses curl, -# to avoid C++ pointer conflicts between curl and RPostgres/libpq) +# 1. Query PostgreSQL for known build failures (before s3fs init to avoid +# C++ pointer conflicts between s3fs/curl and RPostgres/libpq) # --------------------------------------------------------------------------- cat("Connecting to PostgreSQL...\n") con <- DBI::dbConnect( @@ -96,71 +97,62 @@ cran_dt <- data.table( ) # --------------------------------------------------------------------------- -# 3. Published binaries +# 3. S3 tarballs # --------------------------------------------------------------------------- -# Read the slot's own index rather than listing the bucket. An object being -# present does not mean a binary was built: when a build fails, bincraft -# publishes the CRAN source tarball in its place so the package stays -# installable, and a bucket listing cannot tell the two apart. That is how -# amd64/alpine324 came to hold 13,547 CRAN sources that this audit never -# reported. bincraft stamps `Built` only on records it actually built, so the -# index answers the question a listing cannot. -# -# A slot last indexed by a bincraft that predates the source-fallback fix -# stamps `Built` on every record, including the fallbacks, so this reports -# exactly what it used to until that slot is re-indexed. -index_url <- sprintf( - "https://cran.rpkgs.com/%s/%s/latest/src/contrib/PACKAGES.gz", +cat("Connecting to S3...\n") +s3fs::s3_file_system( + aws_access_key_id = Sys.getenv("B2_S3_ACCESS_KEY"), + aws_secret_access_key = Sys.getenv("B2_S3_SECRET_KEY"), + endpoint = "https://s3.eu-central-003.backblazeb2.com", + region_name = "eu-central-003", + refresh = TRUE +) + +s3_path <- sprintf( + "devxy-rpkgs-binaries/%s/%s/latest/src/contrib", arch, s3_codename ) -cat(sprintf("Reading package index: %s\n", index_url)) +cat(sprintf("Listing S3 path: %s\n", s3_path)) -index <- tryCatch( - { - con <- gzcon(url(index_url, open = "rb")) - on.exit(close(con), add = TRUE) - read.dcf(con, fields = c("Package", "Version", "Built")) - }, +s3_pkgs <- tryCatch( + s3fs::s3_dir_ls(s3_path, recurse = FALSE), error = function(e) { cat(sprintf( - "WARNING: Could not read %s: %s\n", - index_url, + "WARNING: Could not list S3 path %s: %s\n", + s3_path, conditionMessage(e) )) - NULL + character(0) } ) -if (is.null(index) || nrow(index) == 0) { - binary_dt <- data.table(Package = character(0), Version = character(0)) +file_names <- basename(s3_pkgs) +matches <- regexec("^([A-Za-z0-9.]+)_([0-9][^/]*)\\.tar\\.gz$", file_names) +parts <- regmatches(file_names, matches) +parts <- parts[sapply(parts, length) == 3] +if (length(parts) == 0) { + s3_dt <- data.table(Package = character(0), Version = character(0)) } else { - built <- !is.na(index[, "Built"]) - binary_dt <- data.table( - Package = as.character(index[built, "Package"]), - Version = as.character(index[built, "Version"]) + s3_dt <- data.table( + Package = sapply(parts, `[`, 2), + Version = sapply(parts, `[`, 3) ) - cat(sprintf( - "Index holds %d records, %d of them built binaries (%d served as CRAN source)\n", - nrow(index), - sum(built), - sum(!built) - )) } cat(sprintf( - "S3 contains %d binaries for %s/%s\n", - nrow(binary_dt), + "S3 contains %d tarballs for %s/%s\n", + nrow(s3_dt), arch, s3_codename )) # --------------------------------------------------------------------------- -# 4. Find missing packages (CRAN release version without a binary in S3) +# 4. Find missing packages (CRAN release version not in S3) # --------------------------------------------------------------------------- setkey(cran_dt, Package, Version) -setkey(binary_dt, Package, Version) -missing_dt <- cran_dt[!binary_dt] +setkey(s3_dt, Package, Version) +missing_dt <- cran_dt[!s3_dt] cat(sprintf("%d CRAN release packages missing from S3\n", nrow(missing_dt))) # --------------------------------------------------------------------------- @@ -336,6 +328,7 @@ if (nchar(forgejo_token) == 0) { } plat_header <- sprintf("## %s", platform) + arch_header <- sprintf("### %s", arch) plat_idx <- which(lines == plat_header) @@ -366,40 +359,31 @@ if (nchar(forgejo_token) == 0) { } plat_lines <- lines[seq(pi, plat_end)] + arch_local_idx <- which(plat_lines == arch_header) - # Arch subsection headers within the platform block (### arm64 / ### amd64). - # Match by prefix: headers carry a " (N missing, M to rebuild)" suffix, so - # exact-equality matching never found the existing block and silently - # appended a duplicate on every run. Remove *all* blocks for this arch - # (collapsing any previously accumulated duplicates), then write one fresh - # block, so the issue holds a single current subsection per arch. - sub_hdr <- which(grepl("^### ", plat_lines)) - arch_re <- sprintf("^### %s( |$)", arch) - - if (length(sub_hdr) == 0) { - # No arch subsections yet -- append after the platform header/preamble. - new_plat_lines <- c(plat_lines, "", arch_lines) + if (length(arch_local_idx) == 0) { + # Append arch subsection at end of platform block + lines <- c( + lines[seq_len(plat_end)], + "", + arch_lines, + lines[seq(plat_end + 1, length(lines))] + ) } else { - preamble <- plat_lines[seq_len(sub_hdr[1] - 1)] - # Each subsection runs from its ### header to the line before the next - # ### header (#### known-failures stays inside its own block). - sub_end <- c(sub_hdr[-1] - 1, length(plat_lines)) - kept <- character(0) - for (k in seq_along(sub_hdr)) { - block <- plat_lines[seq(sub_hdr[k], sub_end[k])] - if (!grepl(arch_re, block[1])) { - kept <- c(kept, block) - } - } - new_plat_lines <- c(preamble, kept, "", arch_lines) - } + ai <- pi + arch_local_idx[1] - 1 # absolute line index - tail_lines <- if (plat_end < length(lines)) { - lines[seq(plat_end + 1, length(lines))] - } else { - character(0) + # End of arch subsection + next_arch <- which( + grepl("^### |^## |^---", lines) & seq_along(lines) > ai + ) + arch_end <- if (length(next_arch) > 0) next_arch[1] - 1 else plat_end + + lines <- c( + lines[seq_len(ai - 1)], + arch_lines, + lines[seq(arch_end + 1, length(lines))] + ) } - lines <- c(lines[seq_len(pi - 1)], new_plat_lines, tail_lines) } # Rebuild excluded footer diff --git a/plans/2026-08-07-per-minor-edge-routing.md b/plans/2026-08-07-per-minor-edge-routing.md deleted file mode 100644 index 3c3e320..0000000 --- a/plans/2026-08-07-per-minor-edge-routing.md +++ /dev/null @@ -1,166 +0,0 @@ -# Per-R-minor edge routing implementation plan - -Spec: `specs/2026-08-07-per-minor-edge-routing-design.md` - -**Goal:** let a stock `install.packages()` see the per-minor packages by routing `PACKAGES*` requests to `…/src/contrib//`, where `bincraft` publishes a union index. - -**Architecture:** the union is built in `bincraft`; the edge script only redirects index requests, gated on a `UNION_SLOTS` script variable; the script lives in this repo and is applied by OpenTofu. - -**Tech stack:** Deno / TypeScript (Bunny Edge Scripting, SDK 0.12), OpenTofu with `BunnyWay/bunnynet` 0.17, R (bincraft). - -## Global constraints - -- Redirect only `PACKAGES`, `PACKAGES.gz` and `PACKAGES.rds`; never a tarball, because the union index already carries the correct tarball URL for both classes of package. -- Every redirect carries `Cache-Control: no-store`; redirect targets stay UA-independent. -- `UNION_SLOTS` is empty by default, so deploying the script is a no-op until a slot is backfilled. -- A slot is `/`, e.g. `amd64/alpine324`. -- Verified prerequisites: `PACKAGES*` is served `cdn-cache: BYPASS`, so the script sees every index request; `Deno.env.get()` reads script variables; the SDK local server listens on `127.0.0.1:8080`. - ---- - -## Task 1: Edge script and its test matrix - -**Files:** - -- Create: `edge/rpkgs-router.ts` -- Create: `edge/rpkgs-router.test.ts` -- Modify: `justfile` (add `edge-test`) - -**Produces:** a single-file script deployable as `bunnynet_compute_script.content`, reading `UNION_SLOTS` from the environment. - -- [ ] **Step 1: write the test matrix first** - -`edge/rpkgs-router.test.ts` spawns `deno run -A edge/rpkgs-router.ts` with `UNION_SLOTS=amd64/alpine324`, waits for `127.0.0.1:8080`, and issues requests with `redirect: "manual"`. - -Cases, asserted on the `location` header (or its absence): - -| # | path | User-Agent | expectation | -| --- | ------------------------------------------------------ | --------------------------------------------- | ------------------------------------------------------------ | -| 1 | `/amd64/alpine324/latest/src/contrib/PACKAGES.gz` | `R (4.5.3 x86_64-pc-linux-musl …)` | 302 → `…/src/contrib/4.5/PACKAGES.gz` | -| 2 | same | `R (4.6.0 …)` | 302 → `…/src/contrib/4.6/PACKAGES.gz` | -| 3 | same, but slot `amd64/noble` | `R (4.5.3 …)` | no redirect (slot not in `UNION_SLOTS`) | -| 4 | `…/src/contrib/curl_7.1.0.tar.gz` | `R (4.5.3 …)` | no redirect | -| 5 | `…/src/contrib/4.5/PACKAGES.gz` | `R (4.5.3 …)` | no redirect (loop guard) | -| 6 | `…/src/contrib/PACKAGES.gz` | `curl/8.0` | no redirect (no R minor) | -| 7 | `/src/contrib/PACKAGES.gz` | alpine UA with `Alpine Linux … 3.24` | 302 → `/amd64/alpine324/latest/src/contrib/4.5/PACKAGES.gz` | -| 8 | `/src/contrib/PACKAGES.gz` | `R (4.5.3 x86_64-pc-linux-musl …)`, no distro | 302 → `cran.r-project.org`, **not** a `linux-musl` slot | -| 9 | `/src/contrib/foo_1.0.tar.gz` | `R (4.5.1 aarch64-apple-darwin20 …)` | 302 → `/bin/macosx/big-sur-arm64/contrib/4.5/foo_1.0.tar.gz` | -| 10 | `/bin/macosx/big-sur-arm64/contrib/4.5/foo_1.0.tar.gz` | any | 302 → `cran.r-project.org` | -| 11 | any redirect above | — | `cache-control: no-store` | - -- [ ] **Step 2: run the tests and watch them fail** - -`just edge-test` → every case fails, because `edge/rpkgs-router.ts` does not exist. - -- [ ] **Step 3: write `edge/rpkgs-router.ts`** - -Order of evaluation in `onOriginRequest`: - -1. normalise `//` runs in the path -2. darwin `/src/contrib/*` → `/bin/macosx//contrib//` -3. `/bin/macosx/**` → CRAN -4. `/{arch}/{os}/latest/src/contrib/`: pass through if `rest` already starts with `/`, or is not an index file, or the slot is not in `UNION_SLOTS`, or the UA has no R minor; otherwise redirect into `/` -5. `/`, `/src/contrib`, `/src/contrib/**`: resolve arch+os from the UA, redirect to CRAN when the distro is unidentifiable, otherwise redirect to the qualified path, adding `/` under the same index-file rule -6. anything else: pass through - -The R minor comes from either `R/4.5.3` or `R (4.5.3 …)`, so a stock UA is enough. The `linux-gnu` / `linux-musl` fallback in `parseUserAgent` is deleted: those are not slot names. - -- [ ] **Step 4: run the tests until they pass** - -`just edge-test` - -- [ ] **Step 5: commit** - -```bash -git add edge/rpkgs-router.ts edge/rpkgs-router.test.ts justfile -git commit -m "feat(edge): route PACKAGES requests to the per-R-minor slot" -``` - ---- - -## Task 2: Manage the script from OpenTofu - -**Files:** - -- Modify: `cdn.tf` - -**Consumes:** `edge/rpkgs-router.ts` from Task 1. - -- [ ] **Step 1: add the resources** - -```terraform -resource "bunnynet_compute_script" "rpkgs_router" { - type = "middleware" - name = "rpkgs-router" - content = file("${path.module}/edge/rpkgs-router.ts") -} - -resource "bunnynet_compute_script_variable" "rpkgs_router_union_slots" { - script = bunnynet_compute_script.rpkgs_router.id - name = "UNION_SLOTS" - default_value = "" - required = false -} -``` - -and replace `middleware_script = 29277` with `middleware_script = bunnynet_compute_script.rpkgs_router.id`. - -- [ ] **Step 2: validate** - -`tofu init -backend=false && tofu validate` - -- [ ] **Step 3: import the existing script (needs `BUNNYNET_API_KEY`)** - -```bash -tofu import bunnynet_compute_script.rpkgs_router 29277 -tofu plan -``` - -The plan must show an in-place `content` update and **no** replacement of the pull zone. A replacement means the import did not take. - -- [ ] **Step 4: commit** - -```bash -git add cdn.tf -git commit -m "feat(cdn): manage the edge middleware script from this repo" -``` - ---- - -## Task 3: Union index writer in bincraft - -**Files (repo `codefloe.com/rpkgs/bincraft`):** - -- Modify: `R/package_index.R` -- Test: `tests/testthat/test-package_index.R` - -**Produces:** `write_union_index(flat_records, minor_records)` returning the merged records, called from `upload_package_index()` when `r_minor` is set. - -- [ ] **Step 1: write the failing tests** - -- a package present in both slots keeps the per-minor record, with `Path = "4.5"` -- a package only in the flat slot survives with no `Path` -- a package only in the per-minor slot survives with `Path = "4.5"` -- a union smaller than the flat input raises an error rather than returning - -- [ ] **Step 2: run them and watch them fail** - -`Rscript -e 'testthat::test_file("tests/testthat/test-package_index.R")'` - -- [ ] **Step 3: implement `write_union_index()` and call it from `upload_package_index()`** - -After `update_PACKAGES()` has written the per-minor index, read the flat slot's `PACKAGES.rds`, set `Path = ` on the per-minor records, drop the flat records for packages the per-minor slot already has, and rewrite `PACKAGES`, `PACKAGES.gz` and `PACKAGES.rds` in the per-minor slot. - -- [ ] **Step 4: run the tests until they pass** - -- [ ] **Step 5: commit and open the PR against bincraft** - ---- - -## Task 4: Roll out slot by slot - -- [ ] Re-index one slot (`amd64/alpine324`, R 4.5) and confirm the union index lists both `curl` (per-minor, `Path: 4.5`) and `jsonlite` (flat, no `Path`). -- [ ] Set `UNION_SLOTS = "amd64/alpine324"` and confirm in `reg.devxy.io/r/r-alpine:4.5-3.24` that `available.packages()` returns the union count and `"curl" %in% rownames(...)`. -- [ ] Add `arm64/alpine324`, then the remaining slots. - -`install.packages("curl")` will still fail to build on `alpine324` until that slot's source tarballs are replaced with real binaries. That is tracked separately. diff --git a/provider.tf b/provider.tf index cb5a87d..b267fcc 100644 --- a/provider.tf +++ b/provider.tf @@ -2,7 +2,7 @@ terraform { required_providers { bunnynet = { source = "registry.terraform.io/BunnyWay/bunnynet" - version = "~> 0.19" + version = "~> 0.15" } } } diff --git a/renovate.json b/renovate.json index 7b2c3b3..53c7306 100644 --- a/renovate.json +++ b/renovate.json @@ -1,12 +1,7 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["local>devxy/renovate-config"], - "ignorePaths": [ - "docker/**", - ".crow/process-updates.yaml", - ".crow/build-all-versions.yaml", - ".crow/weekly-rebuild-missing.yaml" - ], + "ignorePaths": ["docker/**"], "customManagers": [ { "customType": "regex", @@ -19,13 +14,6 @@ "packageNameTemplate": "https://codefloe.com/rpkgs/bincraft.git", "datasourceTemplate": "git-tags", "extractVersionTemplate": "^v?(?.+)$" - }, - { - "customType": "regex", - "fileMatch": ["^local/uvr-install\\.sh$"], - "matchStrings": [ - "# renovate: datasource=(?\\S+) depName=(?\\S+)\\s+UVR_PIN=\"(?[^\"]+)\"" - ] } ] } diff --git a/scripts/purge_cdn_zone.sh b/scripts/purge_cdn_zone.sh deleted file mode 100755 index 8e85cb6..0000000 --- a/scripts/purge_cdn_zone.sh +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env bash -# -# Purge the entire BunnyCDN pull zone. -# -# `purge_cdn_cache.sh` purges the five index files by URL, which is right after -# a normal update: new packages arrive at new URLs, so only the index is stale. -# -# A rebuild is different. It replaces an object *in place*: a package whose -# build failed was published as its CRAN source, and the rebuilt binary takes -# exactly the same URL. The zone caches tarballs for ~370 days -# (`cache_expiration_time` in cdn.tf), so without a purge every client keeps -# receiving the source tarball for up to a year, and nothing about it looks -# wrong from the outside. -# -# Purging per URL would mean one API call per replaced package -- ~13.5k per -# arch against a rate-limited endpoint, where a single missed call leaves a -# silently stale package. One zone purge is a single call regardless of how many -# objects were replaced. The cost is a cold cache for everything else, which is -# why this is not used by the daily update path. -# -# The public hostnames currently use separate pull zones, so callers must pass -# every zone that serves the repository. A zone can be identified by its -# numeric ID or by one of its hostnames; hostname lookup avoids persisting IDs -# that change when a zone is recreated. -# -# Usage: -# purge_cdn_zone.sh [...] -# -set -euo pipefail - -if (($# < 2)); then - echo "usage: $0 [...]" >&2 - exit 2 -fi - -api_key="$1" -shift - -resolve_zone_id() { - local zone="$1" - local response_file - local zone_id - - if [[ "${zone}" =~ ^[0-9]+$ ]]; then - echo "${zone}" - return - fi - - response_file=$(mktemp) - local status - status=$( - curl -sS -o "${response_file}" -w '%{http_code}' \ - -H "AccessKey: ${api_key}" \ - "https://api.bunny.net/pullzone?perPage=1000" - ) - - if [[ "${status}" != "200" ]]; then - echo "Listing BunnyCDN pull zones failed with HTTP ${status}:" >&2 - head -c 500 "${response_file}" >&2 - echo >&2 - rm -f "${response_file}" - exit 1 - fi - - # The endpoint answers with a bare array on some accounts and a paginated - # object on others. `.Items // .` looks like it covers both but does not: - # indexing an array with a string is an *error*, and `//` only substitutes - # for null, so the array case aborted with - # "Cannot index array with string" and the zone was never purged. - zone_id=$( - jq -r --arg hostname "${zone}" \ - '(if type == "object" then (.Items // []) else . end)[] - | select(any(.Hostnames[]?; .Value == $hostname)) - | .Id' \ - "${response_file}" - ) - rm -f "${response_file}" - - if [[ -z "${zone_id}" ]]; then - echo "Could not find BunnyCDN pull zone for hostname ${zone}" >&2 - exit 1 - fi - - # Two zones sharing a hostname would purge only whichever jq emitted first. - if [[ $(wc -l <<<"${zone_id}") -gt 1 ]]; then - echo "Hostname ${zone} matched multiple pull zones: ${zone_id//$'\n'/ }" >&2 - exit 1 - fi - - echo "${zone_id}" -} - -for zone in "$@"; do - zone_id=$(resolve_zone_id "${zone}") - echo "Purging BunnyCDN pull zone ${zone_id}" - - response_file="/tmp/purge_zone_response_${zone_id}.txt" - status=$( - curl -sS -o "${response_file}" -w '%{http_code}' -X POST \ - -H "AccessKey: ${api_key}" \ - -H "Content-Length: 0" \ - "https://api.bunny.net/pullzone/${zone_id}/purgeCache" - ) - - if [[ "${status}" != "200" && "${status}" != "204" ]]; then - echo "Purge of pull zone ${zone_id} failed with HTTP ${status}:" >&2 - cat "${response_file}" >&2 - exit 1 - fi - - echo "Purged pull zone ${zone_id} (HTTP ${status})" -done diff --git a/scripts/verify-r-minor-routing.sh b/scripts/verify-r-minor-routing.sh deleted file mode 100755 index 190245a..0000000 --- a/scripts/verify-r-minor-routing.sh +++ /dev/null @@ -1,434 +0,0 @@ -#!/usr/bin/env bash -# -# Verify per-R-minor index routing for cran.rpkgs.com across every published -# / slot. -# -# The edge router (edge/rpkgs-router.ts) rewrites PACKAGES* requests to -# `contrib//` when the slot is listed in UNION_SLOTS and the client's -# User-Agent carries an R minor. Two properties have to hold before a slot may -# be added to UNION_SLOTS: -# -# 1. the per-minor index is a UNION of the per-minor and flat slots, so -# routing to it hides nothing the flat index carries; and -# 2. every R minor a client might report resolves to an index that exists, -# because contribPath() does not check existence and has no fallback. -# -# Modes: -# (default) Resolve routing decisions without depending on UNION_SLOTS being -# set. Safe to run before enabling: it reads the per-minor indexes -# directly and reproduces the router's target path. -# --live Additionally drive the real CDN with R User-Agents and assert the -# bytes served match the expected index. Only meaningful once the -# slot is in UNION_SLOTS. -# -# Usage: -# scripts/verify-r-minor-routing.sh -# scripts/verify-r-minor-routing.sh --live -# MINORS="4.4 4.5" SAMPLE=10 scripts/verify-r-minor-routing.sh -# -# Exits non-zero if any check fails. - -set -uo pipefail - -BASE=${BASE:-https://cran.rpkgs.com} -ARCHES=${ARCHES:-"amd64 arm64"} -DISTROS=${DISTROS:-"resolute noble jammy rhel8 rhel9 rhel10 alpine323 alpine324"} -# The supported window: the current R minor plus the two previous, matching -# build-env-images' R_VERSION_LATEST/PREV1/PREV2 and cdn.tf's -# local.rpkgs_supported_minors. Each of these must have a published index. -MINORS=${MINORS:-"4.4 4.5 4.6"} -# Minors we deliberately do not serve. These must have NO published index and, -# once routing is live, must be sent to CRAN for sources rather than 404ing or -# being handed binaries built under another minor. -EXCLUDED_MINORS=${EXCLUDED_MINORS:-"4.3"} -# How many Path: targets to HEAD-check per slot/minor. 0 disables. -SAMPLE=${SAMPLE:-5} -# Package-count shortfall against the best minor on the same slot, above which -# coverage is reported as uneven. Reported, not failed on: the packages a -# non-primary minor lacks are ABI-risky ones built under the primary minor, -# which a client on another minor cannot safely load anyway, so their absence -# is correct. This gates the *claim* ("full coverage for ABI-sensitive -# packages"), not whether routing is safe to enable - MAX_REGRESSIONS does -# that. -PARITY_TOLERANCE=${PARITY_TOLERANCE:-25} -# Source fallbacks are reported, not failed on. Since bincraft learned to keep -# a matching-minor generic binary out of a fallback's shadow, a remaining -# fallback means the generic slot's binary was built under a *different* minor, -# which is unsafe for this client anyway: serving source there is correct, just -# slow. The gate below is what actually matters. -# -# A REGRESSION is a package this client would receive as source through -# per-minor routing but as a binary built under its own minor from the generic -# slot. That is strictly worse than not routing at all, and must be zero before -# a slot is added to UNION_SLOTS. -MAX_REGRESSIONS=${MAX_REGRESSIONS:-0} -LIVE=0 - -for arg in "$@"; do - case "$arg" in - --live) LIVE=1 ;; - -h | --help) - sed -n '2,32p' "$0" - exit 0 - ;; - *) - echo "unknown argument: $arg" >&2 - exit 2 - ;; - esac -done - -WORK=$(mktemp -d) -trap 'rm -rf "$WORK"' EXIT - -PASS=0 -FAIL=0 -FAILURES="" - -ok() { - PASS=$((PASS + 1)) - printf ' ok %s\n' "$1" -} - -bad() { - FAIL=$((FAIL + 1)) - FAILURES="${FAILURES}\n - $1" - printf ' FAIL %s\n' "$1" -} - -# Fetch a URL into a file, echoing the HTTP status. Cached per URL. -fetch() { - local url=$1 dest=$2 ua=${3:-} - if [ -s "$dest" ]; then - cat "$dest.status" - return 0 - fi - local status - # -L: the router answers an index request with a redirect, so the bytes a - # client ends up with are only visible by following it. - # - # no-cache: a purge is asynchronous, so a run started right after a reindex - # otherwise measures whatever the edge still holds. - if [ -n "$ua" ]; then - status=$(curl -sSL -A "$ua" -H 'Cache-Control: no-cache' -o "$dest" -w '%{http_code}' --max-time 120 "$url" 2>/dev/null) - else - status=$(curl -sSL -H 'Cache-Control: no-cache' -o "$dest" -w '%{http_code}' --max-time 120 "$url" 2>/dev/null) - fi - echo "$status" > "$dest.status" - echo "$status" -} - -head_status() { - curl -sS -o /dev/null -w '%{http_code}' -I --max-time 60 "$1" 2>/dev/null -} - -# Package names from a gzipped PACKAGES index, sorted. -pkg_names() { - gunzip -c "$1" 2>/dev/null | awk '/^Package:/ {print $2}' | sort -u -} - -# " " for a per-minor index: how many entries carry a -# Path: field, and how many of those lack a Built: field (i.e. are sources). -fallback_counts() { - gunzip -c "$1" 2>/dev/null | awk ' - /^Package:/ { pkg = $2; path = ""; built = "" } - /^Path:/ { path = $2 } - /^Built:/ { built = $2 } - /^$/ { if (pkg != "" && path != "") { n++; if (built == "") s++ } pkg = "" } - END { if (pkg != "" && path != "") { n++; if (built == "") s++ } - printf "%d %d\n", n, s } - ' -} - -# Packages this index serves from the generic slot with a binary built under a -# different R minor, while some other per-minor slot carries a build of them - -# which proves the ABI classifier called them risky. Serving those is the -# load-time crash the per-minor slots exist to prevent. bincraft drops them at -# index time, so a non-zero count means the slot has not been reindexed since -# that guard shipped. -abi_unsafe_count() { - local minor_file=$1 minor=$2 risky_file=$3 - gunzip -c "$minor_file" 2>/dev/null | awk -v m="$minor" ' - /^Package:/ { pkg = $2; path = ""; built = "" } - /^Path:/ { path = $2 } - /^Built:/ { built = $2 " " $3 } - /^$/ { if (pkg != "" && path == "" && built != "" && built !~ ("^R " m "\\.")) print pkg; pkg = "" } - ' | sort -u > "$minor_file.mismatched" - comm -12 "$minor_file.mismatched" "$risky_file" | wc -l -} - -# How many packages a client of would receive as source through -# per-minor routing while the generic slot holds a binary built under that very -# minor. Zero is the bar for enabling a slot. -regression_count() { - local minor_file=$1 flat_file=$2 minor=$3 - gunzip -c "$flat_file" 2>/dev/null | awk -v m="$minor" ' - /^Package:/ { pkg = $2; built = "" } - /^Built:/ { built = $2 " " $3 } - /^$/ { if (pkg != "" && built ~ ("^R " m "\\.")) print pkg; pkg = "" } - ' | sort -u > "$minor_file.flatbin" - gunzip -c "$minor_file" 2>/dev/null | awk ' - /^Package:/ { pkg = $2; path = ""; built = "" } - /^Path:/ { path = $2 } - /^Built:/ { built = $2 } - /^$/ { if (pkg != "" && path != "" && built == "") print pkg; pkg = "" } - ' | sort -u > "$minor_file.src" - comm -12 "$minor_file.src" "$minor_file.flatbin" | wc -l -} - -# " " pairs for entries that carry a Path: field. -path_entries() { - gunzip -c "$1" 2>/dev/null | awk ' - /^Package:/ { pkg = $2; ver = ""; path = "" } - /^Version:/ { ver = $2 } - /^Path:/ { path = $2 } - /^$/ { if (pkg != "" && path != "") print pkg, ver, path; pkg = "" } - END { if (pkg != "" && path != "") print pkg, ver, path } - ' -} - -# An R User-Agent of the shape R actually sends. -r_user_agent() { - printf 'R/%s.0 (Ubuntu 24.04; codename=noble) (x86_64-pc-linux-gnu x86_64 linux-gnu)' "$1" -} - -echo "verify-r-minor-routing: $BASE" -echo " slots: $(echo "$ARCHES" | wc -w) arch x $(echo "$DISTROS" | wc -w) os" -echo " minors: $MINORS (excluded: $EXCLUDED_MINORS)" -echo " live: $LIVE" -echo - -for arch in $ARCHES; do - for distro in $DISTROS; do - slot="$arch/$distro" - echo "$slot" - - flat_url="$BASE/$slot/latest/src/contrib/PACKAGES.gz" - flat_file="$WORK/${arch}-${distro}-flat.gz" - flat_status=$(fetch "$flat_url" "$flat_file") - - if [ "$flat_status" != "200" ]; then - bad "$slot flat index unreachable (HTTP $flat_status)" - continue - fi - - pkg_names "$flat_file" > "$flat_file.names" - flat_count=$(wc -l < "$flat_file.names") - if [ "$flat_count" -lt 1000 ]; then - bad "$slot flat index has only $flat_count packages" - continue - fi - ok "$slot flat index: $flat_count packages" - - for minor in $MINORS; do - minor_url="$BASE/$slot/latest/src/contrib/$minor/PACKAGES.gz" - minor_file="$WORK/${arch}-${distro}-${minor}.gz" - minor_status=$(fetch "$minor_url" "$minor_file") - - # A minor the router would route to must exist, or clients on that R - # version get a 404 and see no packages at all. - if [ "$minor_status" != "200" ]; then - bad "$slot R $minor index missing (HTTP $minor_status) - routing would 404 for R $minor clients" - continue - fi - - pkg_names "$minor_file" > "$minor_file.names" - minor_count=$(wc -l < "$minor_file.names") - - # Union property: nothing the flat index carries may be missing here. - # bincraft deliberately drops an ABI-risky package whose only binary was - # built under another R minor: serving it is the load-time crash the - # per-minor slots exist to prevent. Those absences are correct. - # - # What must never go missing is a generic package built under *this* - # minor, which is safe to serve and has no reason to disappear. - comm -23 "$flat_file.names" "$minor_file.names" > "$minor_file.absent" - absent_count=$(wc -l < "$minor_file.absent") - gunzip -c "$flat_file" 2>/dev/null | awk -v m="$minor" ' - /^Package:/ { pkg = $2; built = "" } - /^Built:/ { built = $2 " " $3 } - /^$/ { if (pkg != "" && built ~ ("^R " m "\\.")) print pkg; pkg = "" } - ' | sort -u > "$minor_file.flatsame" - lost=$(comm -12 "$minor_file.absent" "$minor_file.flatsame" | wc -l) - - if [ "${lost:-0}" -ne 0 ]; then - bad "$slot R $minor index dropped $lost generic package(s) built under R $minor, which were safe to serve" - else - ok "$slot R $minor index: $minor_count packages, union holds ($absent_count ABI-unsafe dropped)" - fi - - # A per-minor entry that is a source fallback resolves fine but makes the - # client compile. Routing to a slot that is mostly fallbacks does not - # deliver the binaries we advertise. - read -r steered fallbacks <<< "$(fallback_counts "$minor_file")" - if [ "${steered:-0}" -gt 0 ]; then - pct=$((fallbacks * 100 / steered)) - printf ' note: %s/%s per-minor entries are source fallbacks (%s%%)\n' \ - "$fallbacks" "$steered" "$pct" - fi - - # The gate: nothing may arrive as source here that the generic slot would - # have served as a binary built under this same minor. - regressions=$(regression_count "$minor_file" "$flat_file" "$minor") - if [ "${regressions:-0}" -gt "$MAX_REGRESSIONS" ]; then - bad "$slot R $minor: $regressions packages would be served as source but exist as an R $minor binary in the generic slot" - else - ok "$slot R $minor: no regression against the generic slot" - fi - - # Path: entries steer to per-minor binaries; they must resolve. - if [ "$SAMPLE" -gt 0 ]; then - path_entries "$minor_file" > "$minor_file.paths" - total_paths=$(wc -l < "$minor_file.paths") - broken=0 - checked=0 - while read -r pkg ver path; do - [ -z "${pkg:-}" ] && continue - tarball="$BASE/$slot/latest/src/contrib/$path/${pkg}_${ver}.tar.gz" - status=$(head_status "$tarball") - checked=$((checked + 1)) - if [ "$status" != "200" ]; then - broken=$((broken + 1)) - [ "$broken" -le 2 ] && printf ' broken target: %s (HTTP %s)\n' "$tarball" "$status" - fi - done < <(shuf -n "$SAMPLE" "$minor_file.paths" 2>/dev/null || head -n "$SAMPLE" "$minor_file.paths") - - if [ "$broken" -ne 0 ]; then - bad "$slot R $minor: $broken/$checked sampled Path: targets do not resolve (of $total_paths total)" - elif [ "$checked" -gt 0 ]; then - ok "$slot R $minor: $checked/$checked sampled Path: targets resolve (of $total_paths total)" - fi - fi - - # Live routing: what a real R client on this minor actually receives. - if [ "$LIVE" -eq 1 ]; then - ua=$(r_user_agent "$minor") - live_file="$WORK/${arch}-${distro}-${minor}-live.gz" - live_status=$(fetch "$flat_url" "$live_file" "$ua") - if [ "$live_status" != "200" ]; then - bad "$slot R $minor live request failed (HTTP $live_status)" - elif cmp -s "$live_file" "$minor_file"; then - ok "$slot R $minor live request served the per-minor index" - elif cmp -s "$live_file" "$flat_file"; then - bad "$slot R $minor live request served the FLAT index - slot not in UNION_SLOTS?" - else - bad "$slot R $minor live request served neither the per-minor nor the flat index" - fi - fi - done - - # Coverage parity across minors. The union property only guarantees no - # client loses packages relative to the flat index; it says nothing about a - # 4.4 client seeing fewer packages than a 4.5 client on the same slot. - best=0 - for minor in $MINORS; do - f="$WORK/${arch}-${distro}-${minor}.gz.names" - [ -s "$f" ] || continue - c=$(wc -l < "$f") - [ "$c" -gt "$best" ] && best=$c - done - if [ "$best" -gt 0 ]; then - uneven="" - for minor in $MINORS; do - f="$WORK/${arch}-${distro}-${minor}.gz.names" - [ -s "$f" ] || continue - c=$(wc -l < "$f") - gap=$((best - c)) - [ "$gap" -gt "$PARITY_TOLERANCE" ] && uneven="$uneven R$minor:-$gap" - done - if [ -n "$uneven" ]; then - printf ' note: %s coverage uneven across minors (vs best %s):%s\n' \ - "$slot" "$best" "$uneven" - else - ok "$slot coverage parity across minors (best $best, all within $PARITY_TOLERANCE)" - fi - fi - - # Packages carrying a Path in any per-minor index are risky by construction. - : > "$WORK/${arch}-${distro}.risky" - for minor in $MINORS; do - f="$WORK/${arch}-${distro}-${minor}.gz" - [ -s "$f" ] || continue - gunzip -c "$f" 2>/dev/null | awk ' - /^Package:/ { pkg = $2; path = "" } - /^Path:/ { path = $2 } - /^$/ { if (pkg != "" && path != "") print pkg; pkg = "" } - ' >> "$WORK/${arch}-${distro}.risky" - done - sort -u -o "$WORK/${arch}-${distro}.risky" "$WORK/${arch}-${distro}.risky" - - for minor in $MINORS; do - f="$WORK/${arch}-${distro}-${minor}.gz" - [ -s "$f" ] || continue - unsafe=$(abi_unsafe_count "$f" "$minor" "$WORK/${arch}-${distro}.risky") - if [ "${unsafe:-0}" -gt 0 ]; then - bad "$slot R $minor serves $unsafe ABI-risky package(s) built under another R minor - reindex this slot" - else - ok "$slot R $minor serves no ABI-risky package from another minor" - fi - done - - # Excluded minors: no published index, and under --live a redirect to CRAN. - for minor in $EXCLUDED_MINORS; do - ex_url="$BASE/$slot/latest/src/contrib/$minor/PACKAGES.gz" - ex_status=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 60 "$ex_url" 2>/dev/null) - if [ "$ex_status" = "200" ]; then - bad "$slot R $minor is excluded but an index is published - the two lists disagree" - else - ok "$slot R $minor correctly has no published index" - fi - - if [ "$LIVE" -eq 1 ]; then - loc=$(curl -sS -o /dev/null -w '%{redirect_url}' -A "$(r_user_agent "$minor")" \ - --max-time 60 "$flat_url" 2>/dev/null) - case "$loc" in - https://cran.r-project.org/*) - ok "$slot R $minor is sent to CRAN ($loc)" - ;; - "") - bad "$slot R $minor was served directly instead of being sent to CRAN" - ;; - *) - bad "$slot R $minor redirected somewhere unexpected: $loc" - ;; - esac - fi - done - - # A client whose User-Agent carries no R version must keep getting the flat - # index, never a per-minor one. - if [ "$LIVE" -eq 1 ]; then - plain_file="$WORK/${arch}-${distro}-plain.gz" - plain_status=$(fetch "$flat_url" "$plain_file" "curl/8.0.0") - if [ "$plain_status" != "200" ]; then - bad "$slot non-R User-Agent request failed (HTTP $plain_status)" - elif cmp -s "$plain_file" "$flat_file"; then - ok "$slot non-R User-Agent still served the flat index" - else - bad "$slot non-R User-Agent was routed away from the flat index" - fi - - # Tarball requests must never be rewritten into a per-minor directory: - # flat-slot packages do not live there. - sample_pkg=$(gunzip -c "$flat_file" | awk '/^Package:/ {p=$2} /^Version:/ {print p, $2; exit}') - if [ -n "$sample_pkg" ]; then - # shellcheck disable=SC2086 # deliberate split into $1 (package) and $2 (version) - set -- $sample_pkg - tb="$BASE/$slot/latest/src/contrib/${1}_${2}.tar.gz" - tb_status=$(curl -sS -o /dev/null -w '%{http_code}' -A "$(r_user_agent 4.5)" --max-time 60 "$tb" 2>/dev/null) - if [ "$tb_status" = "200" ]; then - ok "$slot tarball request under an R User-Agent still resolves" - else - bad "$slot tarball ${1}_${2}.tar.gz broke under an R User-Agent (HTTP $tb_status)" - fi - fi - fi - done -done - -echo -echo "passed: $PASS failed: $FAIL" -if [ "$FAIL" -ne 0 ]; then - printf 'failures:%b\n' "$FAILURES" - exit 1 -fi diff --git a/specs/2026-08-07-per-minor-edge-routing-design.md b/specs/2026-08-07-per-minor-edge-routing-design.md deleted file mode 100644 index b55812c..0000000 --- a/specs/2026-08-07-per-minor-edge-routing-design.md +++ /dev/null @@ -1,174 +0,0 @@ -# Design: Routing clients to per-R-minor binary slots - -Date: 2026-08-07 -Status: Approved (pending spec review) - -## Problem - -`bincraft` routes ABI-"risky" packages to a per-minor slot `…/latest/src/contrib//` and indexes every directory independently (`upload_package_index()` calls `cranlike::update_PACKAGES()` on one prefix at a time). -Nothing unions those indices, and `contrib.url()` only ever yields `/src/contrib`, so no value of `options(repos)` can address a per-minor slot. -Only `uvr` resolves per-minor URLs, which means the per-minor slots are invisible to `install.packages()` by construction. - -Measured on 2026-08-07: - -| slot | flat `src/contrib` | `src/contrib/4.5` | unique packages only in the per-minor slot | -| ----------------- | ------------------ | ----------------- | ------------------------------------------ | -| `amd64/alpine324` | 21 640 | 3 310 | 2 886 | -| `amd64/noble` | 24 495 | 398 | 23 | - -This is what issue #63 records as "missing binaries" on `alpine324`. -The packages are not missing; they are in a directory base R cannot reach. -The user-visible symptom in `reg.devxy.io/r/r-alpine:4.5-3.24` is: - -``` -> install.packages("curl") -Warning message: -package 'curl' is not available for this version of R -``` - -A second, unrelated defect exists on the same slot and is **out of scope here**: many `alpine324` tarballs are byte-identical CRAN _source_ tarballs that the index nevertheless stamps `Built: R 4.5.3; …-linux-musl`. -Routing exposes `curl`; only a rebuild of that slot makes it install. - -## Goal - -Let a stock `install.packages()` see one complete package list for its own R minor, without duplicating tarballs and without an R-version-varying cache key anywhere in the CDN. - -## Key constraint that drives the design - -R resolves a package's download URL from the index, not from the request path, and it keeps the `contriburl` it _asked for_ rather than the one it was redirected to. -Measured with `options(repos = …/latest)` against a middleware that redirects the index into `4.5/`: - -``` -curl available: TRUE -curl repo: …/latest/src/contrib # the flat URL, not the 4.5 one it was served from -``` - -So the union index is always addressed relative to the **flat** directory, whatever path it was fetched from. -`available.packages()` honours a `Path:` field and folds it into the `Repository` column, which gives the whole routing for free: - -- a per-minor record carries `Path: `, so its tarball is fetched from `…/src/contrib//` -- a flat record carries no `Path`, so its tarball is fetched from `…/src/contrib/` - -Verified end to end against the live CDN with a locally built union index for `amd64/alpine324` (31 507 records): - -``` -curl: 7.1.0 -> …/latest/src/contrib/4.5 -> curl_7.1.0.tar.gz 717 725 B -jsonlite: 2.0.0 -> …/latest/src/contrib -> jsonlite_2.0.0.tar.gz 1 055 849 B -``` - -The corollary is that the edge script must **not** rewrite tarball requests: every tarball URL is already correct when it leaves the client, and redirecting one into `/` would break exactly the flat packages the union is meant to preserve. - -The complementary trick does not work: R's `gzcon()` reads only the first member of a concatenated gzip stream (10 291 of an expected 31 931 records), so an edge-side merge would have to fully decompress and recompress both indices and additionally 404 `PACKAGES.rds` to stop R preferring it. -That is why the union is produced in `bincraft`, not at the edge. - -## Approaches considered - -| Approach | Where the union lives | Verdict | -| ----------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -| **A. Union index written by `bincraft` (chosen)** | per-minor `PACKAGES*`, per-minor entries carry `Path: ` | Edge does one redirect; `PACKAGES.rds` stays correct; no duplication | -| B. Merge at the edge | middleware fetches both indices, recompresses | ~2 MB decompress/recompress per cache fill, cache key must include the R minor, breaks R's `.rds` fast path | -| C. Move the minor up the path (`latest//src/contrib/`) | addressable by `options(repos)` directly | No edge logic at all, but a full layout migration and breaks the published URL contract | - -Chosen: **A**. - -## Architecture - -### bincraft: union index (separate PR) - -After writing a per-minor index, republish it as a union of that slot and the flat slot: - -1. Read the flat slot's `PACKAGES.rds` and the per-minor slot's own records. -2. Set `Path: ` on every per-minor record, so its tarball resolves into the per-minor directory. -3. Drop every flat record whose package is already present in the per-minor slot, so the per-minor build always wins, and leave the survivors without a `Path`. -4. Write the merged `PACKAGES`, `PACKAGES.gz` and `PACKAGES.rds` into `…/src/contrib//`. - -Guard: refuse to publish a union with fewer records than the flat index it was built from. -A truncated union is worse than no union, because it silently removes packages from every client on that minor. - -### Edge script (this repo) - -The script routes `PACKAGES`, `PACKAGES.gz` and `PACKAGES.rds` requests, and nothing else. - -``` -normalize path -parseClient(UA) -> { rMinor, arch, os } # rMinor from "R (4.5.3 …)" or "R/4.5.3" -darwin branches # unchanged -if path is /{arch}/{os}/latest/src/contrib/PACKAGES* - already under // ? pass through # loop guard - rMinor known && slot in UNION_SLOTS ? 302 -> …/src/contrib//PACKAGES* - else pass through # flat slot, today's behaviour -if path is /src/contrib/… # bare root - resolve arch + os; unknown -> 302 to CRAN - then apply the same PACKAGES* rule -else pass through -``` - -Redirects carry `Cache-Control: no-store`. -Every cacheable URL is therefore UA-independent, and no cache key has to vary by R version. - -### Repaired bare-root detection - -The bare `https://cran.rpkgs.com` form is currently broken for every Linux client that uses a stock R user agent. -`ALPINE_REGEX`, `UBUNTU_REGEX` and `RHEL_REGEX` only match a Posit-style user agent the user has to set by hand; stock R never carries the distro, so the script falls through to `extractOs()` and redirects to a slot that does not exist: - -``` -UA: R (4.5.3 x86_64-pc-linux-musl …) -> 302 /amd64/linux-musl/latest/… (404) -UA: R (4.5.3 x86_64-pc-linux-gnu …) -> 302 /amd64/linux-gnu/latest/… (404) -``` - -The fallback to a phantom `linux-musl` / `linux-gnu` slot is removed. -An unidentifiable distro redirects to CRAN, which is the existing behaviour for an unparseable user agent. -The R _minor_ is always present in a stock user agent, so per-minor routing itself does not depend on distro detection. - -### Rollout gate - -`UNION_SLOTS` is a `bunnynet_compute_script_variable` listing the slots whose per-minor index is already a union. -It is empty by default, so deploying the script changes nothing until `bincraft` has backfilled a slot, and a rollback is a variable edit rather than a code deploy. -All slots currently carry `4.4`, `4.5` and `4.6`; a client on any other minor falls through to the flat slot. - -### Deployment from this repo - -The script is a file in the repo, applied by the existing OpenTofu configuration: - -``` -edge/rpkgs-router.ts # the script -edge/rpkgs-router.test.ts # UA x path -> expected Location matrix -cdn.tf # bunnynet_compute_script + _variable -``` - -Provider `BunnyWay/bunnynet` v0.17.0 (already pinned) ships `bunnynet_compute_script` with `content` loadable via `file()`, plus `bunnynet_compute_script_variable`. -`middleware_script = bunnynet_compute_script.rpkgs_router.id` replaces the hard-coded `29277`, after a one-time `tofu import` of the existing script. - -## Error handling - -- Unknown R minor, or a slot not listed in `UNION_SLOTS`: pass through to the flat slot. - The client sees exactly today's behaviour. -- Unparseable distro on the bare-root form: redirect to CRAN. -- A request already under `…/src/contrib//`: pass through, so a redirect can never loop. -- A per-minor slot that does not exist for a listed minor: the client gets the origin's 404. - `UNION_SLOTS` is the operator's assertion that the slot is ready, so this is a configuration error, not a runtime condition to paper over. - -## Testing - -Local, before any apply: `deno run -A edge/rpkgs-router.ts` serves the middleware against the real origin, so `edge/rpkgs-router.test.ts` drives the whole matrix against that local server. - -- User agent matrix: R 4.4 / 4.5 / 4.6 on musl and gnu, both arches, Posit-style and stock forms, plus a darwin UA and a non-R UA. -- Path matrix: `PACKAGES`, `PACKAGES.gz`, `PACKAGES.rds`, a tarball, a path already under `4.5/`, and `/src/contrib/…` on the bare root. -- Assertion is the `Location` header (or its absence), not the body. - -After apply, a smoke test against `cran.rpkgs.com`: - -- `available.packages()` inside `reg.devxy.io/r/r-alpine:4.5-3.24` returns the union count, and `"curl" %in% rownames(...)` is `TRUE`. -- A flat-slot package still downloads from `…/src/contrib/`, and a per-minor package downloads from `…/src/contrib//`. - -## Out of scope - -- `Meta/archive.rds` stays flat-only, so `remotes::install_version()` does not see per-minor archives. -- The `alpine324` source-tarball defect: that slot serves CRAN sources stamped as binaries, and needs a rebuild independent of this work. -- Any change to how `uvr` resolves per-minor URLs; it already addresses the slots directly. - -## Split of work - -1. `bincraft`: union index writer plus its guard, and a re-index of one slot to validate. -2. This repo: `edge/rpkgs-router.ts`, its test matrix, and the `cdn.tf` resources with `UNION_SLOTS` empty. -3. Enable `UNION_SLOTS` slot by slot as `bincraft` backfills them. diff --git a/specs/2026-08-12-shard-weekly-rebuild-design.md b/specs/2026-08-12-shard-weekly-rebuild-design.md deleted file mode 100644 index f4ccbaa..0000000 --- a/specs/2026-08-12-shard-weekly-rebuild-design.md +++ /dev/null @@ -1,167 +0,0 @@ -# Design: Sharding and resuming the weekly rebuild - -Date: 2026-08-12 -Status: Approved (pending spec review) - -## Problem - -`weekly-rebuild-missing` runs one job per `-` and walks that slot's rebuild list serially in a single `R -q -e` invocation (`.crow/weekly-rebuild-missing.yaml:165`). -Until 2026-08-09 that was cheap, because every source fallback was skipped as "already built" and the list was effectively empty. -Since bincraft #105/#106/#107 and build-cran-binaries #159 the gate works, and the lists are now large. - -Share of records whose object is byte-identical to CRAN's source, measured against `cran.r-project.org` MD5s on 2026-08-12: - -| slot | records | source-served | share | -| ------------------ | ------: | ------------: | -----------: | -| `amd64/resolute` | 24 212 | 15 023 | 62.1% | -| `arm64/resolute` | 24 291 | 13 670 | 56.3% | -| `arm64/alpine324` | 24 328 | 9 514 | 39.2% | -| `amd64/alpine324` | 24 343 | 8 917 | 36.7% | -| `arm64/rhel10` | 24 695 | 5 384 | 21.9% | -| `amd64/rhel10` | 24 881 | 4 712 | 19.2% | -| 12 remaining slots | ~24 700 | 850 to 2 130 | 3.5% to 8.7% | - -A single serial job cannot absorb that. -Pipeline 10910 (`weekly_rebuild_missing:alpine-324-amd64`) started on 2026-08-09, ran for roughly two days, reached `[8692/23885] cholera`, and was killed there. - -Two distinct failures follow from that shape. - -**No parallelism.** The work is embarrassingly parallel across packages, but one job does all of it. - -**No resumability, and no clean stopping point.** The loop has no terminating condition other than exhausting the list, so the only way to stop it is a kill. -A restarted run re-reads the same list and walks it from the first entry. -It skips completed packages via `check_s3_root_package()`, but that costs a CRAN version resolution and an S3 `HEAD` per package, thousands of times, before it reaches new work. -Worse, a kill is not a pipeline failure: the `Purge CDN cache` step is guarded by `when: status: [success, failure]` (`.crow/weekly-rebuild-missing.yaml:206-207`), and on 10910 it produced no output at all. -So the ~4 600 binaries that run did publish stayed hidden behind stale edge copies. - -## Goal - -Turn each slot's rebuild into bounded, parallel, restartable units, without introducing state that can disagree with the bucket. - -## Design - -### 1. Shard the matrix three ways - -Each of the 18 `OS`/`ARCH` rows in `.crow/weekly-rebuild-missing.yaml` gains `SPLIT_INTO: 3` and `SPLIT_INDEX: 1|2|3`, giving 54 rows. -This mirrors `.crow/build-all-versions.yaml:57-98`, which already shards its matrix four ways per arch. - -Routing needs no change. -The cron filter `cron: weekly-rebuild-missing-${OS}-${ARCH}` and the manual `evaluate: weekly_rebuild_missing == "${OS}-${ARCH}"` both match all three shards of a slot. -Placement stays on the `rpkgs-${ARCH}` group label, so shards queue against available capacity rather than oversubscribing it. - -### 2. Extract the loop into `local/rebuild-missing.R` - -The build is currently a single ~1 500-character `R -q -e` argument. -Shard arithmetic and resume logic do not belong in a YAML string, and none of it is testable there. -The loop moves to `local/rebuild-missing.R`, invoked as `Rscript local/rebuild-missing.R $SPLIT_INTO $SPLIT_INDEX`, mirroring `local/build-all.R`. -Its body is unchanged in substance: read `/tmp/rebuild_pkgs.txt`, subtract `local/excluded-packages.json`, loop with `tryCatch` around `bincraft::build_binary_package()`. - -The slice is **interleaved**, not contiguous: - -```r -# the list is alphabetical and build cost clusters by name (Rcpp*, Bioc*, -# rstan*), so contiguous thirds would be badly unbalanced -mine <- pkgs[seq(split_index, length(pkgs), by = split_into)] -``` - -`local/build-all.R:64` uses contiguous chunks via `cut()`. -That is fine there because its list is every CRAN package and version, so the chunks average out. -Here the list is a filtered backlog in which expensive families sit adjacent, so interleaving is the better default. -Interleaving also makes each shard's `[i/n]` progress representative of the slot as a whole. - -### 3. Resume by re-deriving state from the bucket - -Before the loop, the shard performs one `s3fs::s3_dir_info()` on `devxy-rpkgs-binaries///latest/src/contrib` and reads the `etag` column. -It fetches CRAN's `PACKAGES` once for the latest version and published `MD5sum` of every package. -A package is still outstanding if and only if the object at `_.tar.gz` has an ETag equal to CRAN's `MD5sum` for that version, which is the definition `check_s3_root_package()` already applies one package at a time. - -```r -# one paginated listing instead of ~2900 sequential HEAD requests per shard -info <- s3fs::s3_dir_info(slot_dir) -etag <- setNames(gsub('^"|"$', "", info$etag), basename(info$uri)) - -key <- sprintf("%s_%s.tar.gz", mine, cran_version[mine]) -# keep a package when no object exists yet, or when the object is still -# byte-identical to CRAN's source; drop it once a real binary is published -mine <- mine[is.na(etag[key]) | etag[key] == cran_md5[key]] -``` - -This is the whole resume mechanism. -There is no progress file, no volume, and no database cursor. -A restarted shard recomputes ground truth and continues where it stopped, and it is correct even when a sibling shard, a `process-updates` cron, or a manual `just rebuild` completed something in the meantime. - -Three properties make this the right source of truth: - -- **It is what the build itself checks.** Any other store can disagree with the bucket; this one cannot. -- **It is agent-independent.** `.crow/weekly-rebuild-missing.yaml` mounts no `volumes:`, unlike `.crow/build-all-versions.yaml:132-133`, so `/mnt/cache` is per-job and cannot carry progress anyway. -- **It costs one listing.** `cranlike`'s `s3` fork already does exactly this call against this bucket at ~24 000 objects, so the approach is proven at the required scale. - -It must read ETags rather than the slot index's `Built` field, which is how `local/packages-to-build.R:104-130` answers the same question. -Under this design the index is not rewritten until the dependent re-index pipeline runs (section 5), so mid-run it cannot reflect the current run's progress. - -Packages that genuinely fail to build re-publish their CRAN source, so they stay outstanding and would be retried on every restart. -That is already handled upstream: `bincraft::filter_packages_with_errors()` (`R/build_binaries.R:1018`, `:1143`) drops anything with `error_occurred = TRUE`, and `store_build_metadata = TRUE` is passed on every call. -No additional poison-pill filter is needed here. - -Only the flat `src/contrib` path is considered. -The rebuild call passes no `is_r_minor_sensitive`, so it defaults to `FALSE` and only ever targets the flat path; the resume filter matches that scope deliberately. - -### 4. Give each shard a wall-clock budget - -`local/rebuild-missing.R` takes a budget, defaulting to 20 hours, and breaks out of the loop once it is exceeded: - -```r -# exit cleanly rather than being killed, so the dependent re-index still runs -if (difftime(Sys.time(), started, units = "hours") > budget_hours) { - cat(sprintf("Budget of %sh reached after %d/%d packages; stopping cleanly\n", budget_hours, i, n)) - break -} -``` - -It exits 0 and reports how much of the slice it covered. -Every run then has a terminating condition, the re-index and purge always fire, and the remainder is picked up by the next run with no bookkeeping, because section 3 recomputes the outstanding set from scratch. - -### 5. Move the re-index and purge into `.crow/weekly-rebuild-reindex.yaml` - -Three shards per slot means three concurrent `upload_package_index()` calls on the same S3 prefix. -`cranlike::update_PACKAGES()` lists the live bucket, so an early lister that uploads last publishes an index missing its siblings' work. -The re-index steps (`.crow/weekly-rebuild-missing.yaml:171-176`) and the purge step (`:187-207`) therefore leave that file entirely. - -The new file carries: - -```yaml -depends_on: - - weekly-rebuild-missing -runs_on: [success, failure] -``` - -`runs_on: [success, failure]` validates as a workflow-level key under `crow lint`, so a failing shard no longer withholds the re-index. -The file uses the same 18-row matrix and the same `when:` gating as `weekly-rebuild-missing`, so it only re-indexes slots that actually ran. -Each row re-indexes the flat slot and every per-minor slot. -`scripts/purge_cdn_zone.sh` runs once on a single row, because all hostnames share pull zone `3857050` and 18 identical zone purges would be waste. - -## Failure behaviour - -| case | today | after | -| -------------------------- | ----------------------------------- | ----------------------------------------------------- | -| one package errors | `tryCatch` logs, loop continues | unchanged | -| a shard fails outright | purge runs, re-index does not | re-index and purge run via `runs_on` | -| a shard exceeds its budget | cannot happen, runs until killed | exits 0, re-index and purge run | -| a shard is killed | nothing runs | still nothing; trigger the re-index pipeline alone | -| a shard restarts | re-walks the list, HEAD per package | one listing, resumes at the first outstanding package | - -The known cost of `depends_on` being file-level rather than row-level: on the weekly cron no slot is re-indexed until the slowest of all 54 jobs finishes. -The 20-hour budget bounds that at roughly one day. - -## Out of scope - -- `build-all-versions` still cannot rebuild source fallbacks, because `local/build-all.R:113-122` drops every version with any `single_builds` row for the platform and arch, which is precisely the source-fallback set. That is a separate change. -- Bunny Perma-Cache eviction. `scripts/purge_cdn_zone.sh` purges the regular edge cache only; see the note in `CLAUDE.md` and issue history. -- The audit that produces the rebuild list is unchanged. - -## Verification - -- `crow lint .crow/` passes for both pipeline files. -- `local/rebuild-missing.R` gets unit coverage in `local/tests/` for the two pure pieces: the interleaved slice (disjoint, covering, deterministic) and the outstanding-set filter (source-served ETag kept, binary ETag dropped, absent object kept). -- A single-slot manual run of `alpine-324-amd64` shard 1 confirms the listing shortcut against the live bucket, and that the reported outstanding count is close to the 8 917 measured above divided by three. -- Restarting that shard mid-run confirms it resumes rather than replaying, by comparing the outstanding count it reports on the second start.