Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
213d30cea4 fix(build): hold back packages the cran mirror has not picked up yet (#188)
Some checks failed
ci/crow/cron/process-updates/15 Pipeline was successful
ci/crow/cron/process-updates/16 Pipeline was successful
ci/crow/cron/process-updates/11 Pipeline was successful
ci/crow/cron/process-updates/18 Pipeline was successful
ci/crow/manual/build-all-versions-install-deps/1 Pipeline was successful
ci/crow/manual/build-all-versions/1 Pipeline is running
ci/crow/manual/build-all-versions/3 Pipeline is running
ci/crow/manual/build-all-versions/4 Pipeline is running
ci/crow/manual/build-all-versions/2 Pipeline failed
## Motivation

A resolute build aborted on a single package:

```
[23/361] AsyPeer_0.0.1 (r_minor_sensitive=TRUE)
Error: GitHub API error (404): Not Found
x URL not found: <https://api.github.com/repos/cran/AsyPeer/commits>
Retrying in 2 seconds. ... Retrying in 60 seconds.
Error in `rate_sleep()`: ! Request failed after 10 attempts.
Execution halted
```

This is not rate limiting — it is a **404**. `check_for_binary()` reads the published version from the `cran` GitHub mirror, and that mirror lags CRAN. `AsyPeer 0.0.1` was published today at 13:50 UTC and has no repository there yet.

The 404 is permanent, but the call is wrapped in `purrr::insistently` with `max_times = 10` and `pause_cap = 60`, so it retries on a 1/2/4/8/16/32/60/60/60/60 second backoff — about five minutes — and then aborts the whole shard.

## Change

Hold back release versions published within `CRAN_MIRROR_LAG_DAYS` (default 3).

Deferring them 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.

## Measured against the live CRAN index

| lag | held back |
|---|---|
| 1 day | 29 of 24831 (0.12%) |
| **3 days** | **135 (0.54%)** |
| 7 days | 412 (1.66%) |

`AsyPeer` is among the 135 at three days.

## Worth doing separately

Retrying a 404 at all is wrong — it can never succeed, and any other permanent 404 (a package pulled from the mirror, say) will abort a shard the same way. `check_for_binary()` should distinguish a permanent 404 from a transient failure and, when the mirror simply lacks the package, treat the version as unknown rather than fatal. That is a bincraft change and I have not made it here.

Reviewed-on: #188
2026-08-31 17:12:56 +00:00
4bf88ed378 fix(build): scope the already-attempted skip to the running R minor (#187)
Some checks failed
ci/crow/cron/process-updates/7 Pipeline was successful
ci/crow/cron/process-updates/8 Pipeline was successful
ci/crow/cron/process-updates/9 Pipeline was successful
ci/crow/cron/process-updates/10 Pipeline was successful
ci/crow/cron/process-updates/4 Pipeline was successful
ci/crow/cron/process-updates/13 Pipeline was successful
ci/crow/manual/build-all-versions-install-deps/1 Pipeline was successful
ci/crow/manual/build-all-versions/1 Pipeline failed
ci/crow/manual/build-all-versions/3 Pipeline failed
ci/crow/cron/process-updates/14 Pipeline was successful
ci/crow/manual/build-all-versions/2 Pipeline was successful
ci/crow/cron/process-updates/3 Pipeline was successful
ci/crow/manual/build-all-versions/4 Pipeline was successful
## Motivation

The run meant to close the 4.6 gap on `amd64/resolute` barely built anything:

```
[1] "Skipped 2334 already-attempted package versions; 59 remaining for this job"
```

`single_builds` records `r_version` per attempt — `store_build_metadata()` both writes and queries it — but the skip query here ignored that column:

```sql
SELECT name, tag FROM single_builds WHERE platform = $1 AND arch = $2
```

So a non-primary pass skipped every package the **primary** pass had already attempted under a different minor. `build-all.R --sensitive-only` running under R 4.6 skipped packages that had only ever been built for 4.5.

That is the reason the per-minor slots never fill, and why the backlog cannot be worked off by rebuilding: `amd64/resolute` serves a 4.6 client 22322 packages against the 4.5 slot's 26346.

It is also, ultimately, why an R 4.6.1 client got a 4.5-built `rlang` and `undefined symbol: SETLENGTH`. Every other fix in this chain addressed a consequence; this is the cause.

## Change

Scope the skip to the R minor the pass is running under.

Matched on the `major.minor` prefix rather than the full `r_version` string, so a patch bump (4.6.0 → 4.6.1) does not re-attempt the entire catalogue. Verified the prefix extraction against `4.5.3`, `4.6.0`, `4.4.3` and a bare `4.6`, and that the derivation matches what `store_build_metadata()` records.

## Expected effect

The non-primary passes stop skipping wholesale. The first run per slot will be long, since it works off a backlog that has been accumulating for as long as the per-minor slots have existed.

## Verification

- `local/build-all.R` parses.
- Minor derivation checked under R 4.6.1: `4.6`.
- Real effect is only observable from a run; the number to watch is the "Skipped N ... M remaining" line, which should show a far larger `M` for a non-primary pass.

Reviewed-on: #187
2026-08-31 13:34:33 +00:00
448349d075 revert(build): drop 4.6.0 as a primary R version option (#186)
All checks were successful
ci/crow/cron/process-updates/2 Pipeline was successful
#183 added `4.6.0` so the per-minor pass could run under 4.6. It was unnecessary, and it is a footgun.

**Unnecessary:** `build-all-versions` already loops every installed interpreter and runs `local/build-all.R --sensitive-only` for each non-primary minor. The 4.6 pass happens when the pipeline runs with the slot's normal `R_VERSION=4.5.3`. I proposed #183 without reading that loop closely enough.

**Footgun:** `R_VERSION` selects the *primary* minor, and the primary build lands in the **generic** slot. Selecting `4.6.0` for a slot whose generic binaries are 4.5-built would publish 4.6 binaries there and break every 4.5 client — the mirror image of the bug that started all this.

The gaps are being filled by running the pipeline as it already stands (11928 on amd64/resolute).

Reviewed-on: #186
2026-08-31 13:29:03 +00:00
4413f499f1 test(verify): follow redirects, and allow the guard's deliberate drops (#185)
Some checks failed
ci/crow/manual/build-all-versions-install-deps/1 Pipeline was successful
ci/crow/manual/build-all-versions/4 Pipeline was canceled
ci/crow/manual/build-all-versions/1 Pipeline was canceled
ci/crow/manual/build-all-versions/3 Pipeline was canceled
ci/crow/manual/build-all-versions/2 Pipeline was canceled
Two checks that no longer matched the system.

`fetch()` did not pass `-L`, so every `--live` check against a routed index read a 302 body rather than the index a client receives — 48 spurious failures against production. It also did not bypass the edge cache. Both were fixed on the branch behind #180, but that PR merged at `+9/-5`, capturing only the parity commit, so neither reached `main`.

The union check asserted flat ⊆ every per-minor index. Since rpkgs/bincraft#116, that is deliberately false: `amd64/resolute` drops 2228 packages from its 4.6 index because their only binary was built under another R minor. Those absences **are** the fix working.

It now asserts the thing that must hold — no generic package built under *this* minor may go missing — and reports the deliberate drops as context.

Verified against production: `amd64/resolute` goes from 5 failures to 14 passed / 0 failed.

Reviewed-on: #185
2026-08-31 13:01:31 +00:00
0a6c155dca feat(cdn): enable per-R-minor routing in production (#184)
All checks were successful
ci/crow/manual/reindex/1 Pipeline was successful
ci/crow/manual/reindex/3 Pipeline was successful
ci/crow/manual/reindex/5 Pipeline was successful
ci/crow/manual/reindex/6 Pipeline was successful
ci/crow/manual/reindex/7 Pipeline was successful
ci/crow/manual/reindex/9 Pipeline was successful
ci/crow/manual/reindex/10 Pipeline was successful
ci/crow/manual/reindex/11 Pipeline was successful
ci/crow/manual/reindex/12 Pipeline was successful
ci/crow/manual/reindex/13 Pipeline was successful
ci/crow/manual/reindex/14 Pipeline was successful
ci/crow/manual/reindex/15 Pipeline was successful
ci/crow/manual/reindex/16 Pipeline was successful
ci/crow/manual/reindex/17 Pipeline was successful
ci/crow/manual/reindex/8 Pipeline was successful
ci/crow/manual/reindex/4 Pipeline was successful
ci/crow/manual/reindex/18 Pipeline was successful
ci/crow/manual/reindex/2 Pipeline was successful
ci/crow/cron/process-updates/1 Pipeline was successful
## Motivation

Everything built today is unreachable until this is set.

```
> install.packages("rlang")
trying URL 'https://cran.rpkgs.com/amd64/resolute/latest/src/contrib/rlang_1.3.0.tar.gz'
> library(rlang)
  undefined symbol: SETLENGTH
```

No `4.6/` in that path. With `UNION_SLOTS` empty the client resolves against the generic index and never reaches a per-minor binary:

| artifact | size |
|---|---|
| generic, R 4.5-built | **2079570** — what R downloaded |
| `4.6/`, R 4.6-built | 2075106 — correct, unused |

The working binary has existed since 12:13 today. Nothing routes anyone to it.

## Change

Sets production `UNION_SLOTS` to all 16 slots, from the same `local.rpkgs_slots` the staging zone uses.

## Verified before enabling

Against the staging zone, which runs the identical script against the identical origin:

| check | result |
|---|---|
| regressions against the generic slot | 0 across all 16 slots |
| R minor served the per-minor index | 48/48 |
| excluded R minor sent to CRAN | 16/16 |
| client with no R minor still gets generic | 16/16 |
| tarball never rewritten | 16/16 |

## Trade-off, stated plainly

Coverage on a non-primary minor drops where the per-minor build backlog has not been worked off. `amd64/resolute` serves a 4.6 client 22169 packages rather than the generic slot's 24310.

Those ~2100 are ABI-risky packages built under another R minor. They are exactly the ones that would install and then fail at load, so the drop trades a confusing runtime crash for an honest "not available". It shrinks as the 4.6 builds land.

If that trade is unwelcome for some slots, `local.rpkgs_slots` can be narrowed to a subset — `amd64/rhel10` and `amd64/alpine323` have the smallest backlogs — and widened as builds catch up.

## After applying

```sh
BASE=https://cran.rpkgs.com scripts/verify-r-minor-routing.sh --live
```

and the reported case directly:

```sh
docker run --rm --platform linux/amd64 reg.devxy.io/r/r-ubuntu:4.6-resolute \
  R -q -e 'install.packages("rlang"); library(rlang); cat("loaded OK\n")'
```

Reviewed-on: #184
2026-08-31 12:52:29 +00:00
5f901312a6 feat(build): allow the per-minor pass to run under R 4.6 (#183)
All checks were successful
ci/crow/manual/reindex/17 Pipeline was successful
## Motivation

The supported window is the latest R minor plus the two previous, which the build images install as `R_VERSION_LATEST=4.6.0`, `PREV1=4.5.3`, `PREV2=4.4.3`. This pipeline's `R_VERSION` offered only the latter two, so **no pipeline could run `local/build-all.R --sensitive-only` under 4.6** and its per-minor slots kept a backlog.

That backlog is the live bug. `rlang` is built for 4.4 and 4.5 on `amd64/resolute` but never for 4.6, so an R 4.6.1 client is served the generic 4.5.3 binary and dies with `undefined symbol: SETLENGTH`. 2709 records across the 16 slots are in that state.

## Why not weekly-rebuild-missing

I tried that first (#182) and it is the wrong tool, for two independent reasons:

- `weekly-missing-binaries-audit.R` reads only `/latest/src/contrib/PACKAGES.gz` and has no `r_minor` awareness, so its candidate list can only contain packages missing from the **generic** slot.
- `rebuild-missing.R:73` says it outright: *"rebuild passes no `is_r_minor_sensitive`, so it only ever targets the flat"*.

Running it under 4.6 built with the right interpreter and wrote to the wrong slot. It built almost nothing, and I verified it contaminated nothing: `amd64/resolute`'s flat slot is 22503 records at 4.5 and zero at 4.6. #182 should be closed.

`build-all-versions` already runs `--sensitive-only`, documented as "the extra per-minor passes under non-primary R versions". It only needed the option.

## Change

Adds `4.6.0` to `R_VERSION`. Default unchanged.

```sh
crow pipeline create devxy/build-cran-binaries \
  --var target_arch=amd64 --var OS=ubuntu --var OS_VERSION=resolute --var R_VERSION=4.6.0
```

## Follow-up worth doing separately

The audit has no per-minor awareness, so this gap is invisible to every existing check and will silently reopen. Nothing measures per-minor completeness today except `scripts/verify-r-minor-routing.sh`, which was written for routing rather than coverage.

Reviewed-on: #183
2026-08-31 12:29:54 +00:00
a2923bf063 fix(cdn): purge the staging zone too (#181)
All checks were successful
ci/crow/cron/process-updates/12 Pipeline was successful
ci/crow/cron/process-updates/17 Pipeline was successful
ci/crow/cron/process-updates/6 Pipeline was successful
ci/crow/cron/process-updates/5 Pipeline was successful
## Motivation

`cran-rpkgs-test` was added as a second pull zone on the same B2 origin, but it was never added to `BUNNY_PULLZONES`. It therefore keeps serving pre-reindex indexes behind the same ~370 day `cache_expiration_time` as production, and nothing ever refreshes it.

That is not cosmetic. The zone exists to be measured, and a verification run against it measures whatever the edge still holds:

```
production: regressions=0    AGHmatrix Path=NA   Built=R 4.5.3; x86_64-pc
staging   : regressions=161  AGHmatrix Path=4.5  Built=(none)
```

Same objects, same origin, 161 phantom regressions. I chased that number through two wrong diagnoses before noticing production and staging disagreed.

## Change

Add `cran-rpkgs-test.b-cdn.net` to the purge list in both reindex pipelines.

## Note

A `Cache-Control: no-cache` request header is not a substitute. It was added to the verification script and did **not** clear this: bunny does not honour it for these objects. Purging is the mechanism that works.

Reviewed-on: #181
2026-08-31 10:31:35 +00:00
d38a4b5746 test(verify): report uneven coverage instead of failing on it (#180)
All checks were successful
ci/crow/cron/process-updates/16 Pipeline was successful
ci/crow/manual/reindex/3 Pipeline was successful
ci/crow/cron/process-updates/11 Pipeline was successful
ci/crow/cron/process-updates/18 Pipeline was successful
## Motivation

The full 16-slot run came back 139 passed, 5 failed. Four of the five were `coverage uneven across minors` on `resolute` and `alpine324` (both arches) — and they are not defects.

Those slots are built under R 4.5, so their 4.5 union carries ABI-risky packages that only exist as 4.5 builds. A 4.4 or 4.6 client cannot safely load them, which is the whole reason per-minor slots exist. Their absence from the 4.4 and 4.6 indexes is correct behaviour, and failing the run on it blocks four slots that regress nobody.

This is the same mistake as the source-fallback share, which was demoted to a note for the same reason.

## Change

Report uneven coverage; do not fail on it.

The two checks answer different questions and should not share an exit code:

- **`MAX_REGRESSIONS`** gates *enablement*: would routing serve a client source where the generic slot holds a binary of that client's own minor? Must be zero.
- **parity** gates the *claim*: can we advertise full coverage for ABI-sensitive packages? Informative, and currently no.

## Verification

`amd64/resolute` now passes with the shortfall printed as a note:

```
ok    amd64/resolute R 4.6: no regression against the generic slot
      note: amd64/resolute coverage uneven across minors (vs best 24748): R4.4:-345 R4.6:-352
passed: 8   failed: 0
```

`shellcheck` clean.

Reviewed-on: #180
2026-08-31 10:00:49 +00:00
a3004695a7 test(verify): gate on regressions against the generic slot, not fallback rate (#179)
All checks were successful
ci/crow/manual/reindex/14 Pipeline was successful
## Motivation

The readiness check added in #175 failed a slot when more than 10% of its per-minor entries were source fallbacks. That stopped being a meaningful signal once rpkgs/bincraft#113 and #114 landed.

Since bincraft keeps a matching-minor generic binary out of a fallback's shadow, a surviving fallback means the generic slot's binary was built under a **different** minor — unsafe for that client anyway. Serving source there is correct, just slow. Failing on that share blocks slots that are genuinely ready: `amd64/noble` sits at 53% for 4.5 and 4.6 while regressing nobody.

## Change

Gate on the thing that actually decides enablement: packages a client of minor M would receive as **source** through per-minor routing while the generic slot holds a binary built under **M itself**. That is strictly worse than not routing at all, and must be zero.

Fallback share is still printed, as context rather than a verdict.

## Verification

Measured across every reindexed slot and minor after the `reindex=all` run: zero regressions everywhere.

| slot | 4.4 | 4.5 | 4.6 |
|---|---|---|---|
| amd64/noble | 0 | 0 | 0 |
| amd64/jammy | 0 | 0 | 0 |
| amd64/rhel9 | 0 | 0 | 0 |
| amd64/rhel10 | 0 | 0 | 0 |
| amd64/resolute | 0 | 0 | 0 |
| arm64/noble | 0 | 0 | 0 |

`shellcheck` clean; script exercised against the live indexes.

Reviewed-on: #179
2026-08-31 09:55:44 +00:00
85295a9495 fix(cdn): resolve a pull zone when the API answers with a bare array (#178)
## Motivation

Every reindex reports `failure` at the purge step:

```
Purging BunnyCDN pull zone 3857050
Purged pull zone 3857050 (HTTP 204)
jq: error (at /tmp/tmp.eFPFmO:0): Cannot index array with string "Items"
Could not find BunnyCDN pull zone for hostname cran.allianceswisspass.devxy.io
```

`cran.rpkgs.com` purges fine. The Alliance zone never has, so it is still serving objects that rebuilds replaced, behind a ~370-day `cache_expiration_time`.

## The defect

```sh
jq -r '(.Items // .)[] | ...'
```

This was meant to accept both response shapes. It accepts neither: indexing an array with a string is an **error** in jq, not a null, so `//` never gets the chance to substitute and the whole expression aborts. The listing endpoint answers with a bare array for this account, so the lookup has always failed.

## Change

- Select the array explicitly by type instead of relying on `//` to absorb an error.
- Check the HTTP status of the listing call. It was previously used unconditionally, so an auth or rate-limit failure surfaced as "could not find hostname" — pointing at the wrong thing entirely.
- Fail when a hostname matches multiple zones rather than silently purging whichever jq emitted first.
- Request `perPage=1000`, so a paginated response cannot silently truncate the zone list.

## Verification

Ran the current `main` script and the fixed one against a stubbed `curl` returning an array-shaped listing:

```
=== BEFORE (main) ===
Purged pull zone 3857050 (HTTP 204)
jq: error (at ...): Cannot index array with string ("Items")
Could not find BunnyCDN pull zone for hostname cran.allianceswisspass.devxy.io

=== AFTER ===
Purged pull zone 3857050 (HTTP 204)
Purging BunnyCDN pull zone 222
Purged pull zone 222 (HTTP 204)
```

The jq expression was also checked against both an array-shaped and an object-shaped (`.Items`) response; the old one fails the array case, the new one handles both. `shellcheck` clean.

Reviewed-on: #178
2026-08-31 09:55:37 +00:00
f3077677d7 ci: add a reindex-only manual workflow (#177)
## Motivation

`weekly-rebuild-reindex` exists to run after `weekly-rebuild-missing`: it declares `depends_on: weekly-rebuild-missing` and is gated on that workflow's `weekly_rebuild_missing` variable. Triggering it manually therefore also starts hours of package rebuilds.

That is the wrong tool when only the index needs regenerating. After rpkgs/bincraft#113 (v5.1.5), which changes how `union_index_records()` decides what a per-minor index steers to, every object in the bucket is already correct and only `PACKAGES*` is stale. Rebuilding to fix an index is pure waste, and the natural cron would take a full cycle to reach every slot.

## Change

Adds `.crow/reindex.yaml`: the index half on its own, manual only, no dependency on a rebuild.

It reuses the same matrix and the same steps as `weekly-rebuild-reindex` — install the latest bincraft release, republish the generic index, loop the installed R versions republishing each per-minor index, purge the edge. No package is built.

Gated on a new `reindex` variable so it cannot be started by the rebuild gate, defaulting to `none` so a manual pipeline creation (which instantiates every file in `.crow/`) matches no matrix row.

```sh
crow pipeline create devxy/build-cran-binaries --var reindex=all
crow pipeline create devxy/build-cran-binaries --var reindex=ubuntu-2404-amd64
```

## Verification

- `crow lint .crow/` passes.
- Gate is manual-only and evaluates `reindex`, with no `depends_on` and no `runs_on` carried over from the rebuild coupling.

Reviewed-on: #177
2026-08-31 09:55:30 +00:00
aba2063ea0 feat(edge): gate per-minor routing on published minors and add a staging zone (#175)
All checks were successful
ci/crow/cron/process-updates/2 Pipeline was successful
ci/crow/cron/process-updates/7 Pipeline was successful
ci/crow/cron/process-updates/8 Pipeline was successful
ci/crow/cron/process-updates/9 Pipeline was successful
ci/crow/cron/process-updates/1 Pipeline was successful
ci/crow/cron/process-updates/10 Pipeline was successful
ci/crow/cron/process-updates/4 Pipeline was successful
ci/crow/cron/process-updates/13 Pipeline was successful
ci/crow/cron/process-updates/14 Pipeline was successful
ci/crow/cron/process-updates/15 Pipeline was successful
ci/crow/cron/process-updates/3 Pipeline was successful
## Motivation

`UNION_SLOTS` is empty, so per-minor routing has never been exercised end to end. Before it can be enabled and advertised, two things were missing: a way to test it without pointing production at it, and evidence that the published indexes actually support it.

Verifying the data first turned up a defect that would have broken users the moment the flag was flipped.

## The defect

`contribPath()` redirects to `contrib/<minor>/` whenever the User-Agent carries any R minor, with no existence check and no fallback:

```ts
const rMinor = extractRMinor(userAgent);
return rMinor ? `/${slot}/latest/src/contrib/${rMinor}/${rest}` : flat;
```

Only `4.4`, `4.5` and `4.6` are published. `4.3` and `4.2` return 404 on all 16 slots. With `UNION_SLOTS` set, an R 4.3 client would be redirected to a non-existent index and see **zero** packages: a silent, total failure rather than a degraded one. R 4.3 is still advertised as supported on the website and in `docs/configuration.mdoc`, though `build-env-images` now pins only 4.6.0/4.5.3/4.4.3.

## Changes

- **Gate routing on `KNOWN_MINORS`** (default `4.4,4.5,4.6`), falling back to the flat index for anything else. Unknown minor now behaves exactly as today.
- **Honour `EXTRA_PUBLIC_HOSTS`.** `publicCdnOrigin()` falls back to the hardcoded `PUBLIC_CDN_ORIGIN` for any hostname not in `PUBLIC_CDN_HOSTS`, so a staging zone on a `b-cdn.net` hostname would redirect into *production* and silently measure the wrong system. This lets the identical script run on staging and redirect within itself.
- **Add the `cran-rpkgs-test` pull zone** with `UNION_SLOTS` pre-enabled for all 16 slots, same B2 origin, served on the bunny default hostname so it needs no DNS record and is never advertised.
- **Add `scripts/verify-r-minor-routing.sh`**, covering every `<arch>/<os>` slot: index reachability per minor, the union property against flat, `Path:` target resolution, coverage parity across minors, and with `--live` the real User-Agent routing, the non-R User-Agent case, and that tarballs are never rewritten.
- **Cover the fallback in the edge test suite** for both an unpublished minor (4.3) and a future one (4.7).

## Findings from the full run

112 passed, 16 failed across the 16 slots. Every failure is the same: no R 4.3 index.

All 16 slots carry union indexes that are supersets of flat, every sampled `Path:` target resolves, and all indexes were republished within minutes of each other, so the build side is healthy.

Coverage is **not** yet even, which is why "full coverage for ABI-sensitive packages" is not a claim to make yet:

| slot | flat | 4.4 | 4.5 | 4.6 |
|---|---|---|---|---|
| amd64/resolute | 24305 | 24402 | 24748 | 24395 |
| amd64/alpine324 | 24397 | 24457 | 24744 | 24448 |
| amd64/noble | 24780 | 24805 | 24805 | 24805 |

On the R 4.5-built distros (`resolute`, `alpine324`, and their arm64 twins) a 4.4 or 4.6 client sees ~300 fewer packages than a 4.5 client. On `noble`/`jammy`/`rhel9`/`alpine323` the spread is under 5. The new parity check encodes this with a configurable `PARITY_TOLERANCE`.

## Verification

- `just edge-test`: 18 steps pass. The two new steps were confirmed to fail with the `KNOWN_MINORS` gate removed and pass with it.
- `tofu validate`: passes. **Not applied** - no bunny.net or state credentials were available, so the staging zone still needs a `tofu apply`.
- `scripts/verify-r-minor-routing.sh`: full 16-slot run, results above.
- `shellcheck`: clean.

## Not done here

Applying the staging zone, then running `BASE=https://cran-rpkgs-test.b-cdn.net scripts/verify-r-minor-routing.sh --live` against it. Production `UNION_SLOTS` is deliberately left empty.

Reviewed-on: #175
2026-08-30 21:20:16 +00:00
10 changed files with 929 additions and 24 deletions

View file

@ -35,6 +35,14 @@ variables:
- '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

193
.crow/reindex.yaml Normal file
View file

@ -0,0 +1,193 @@
# 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
# `<os>-<arch>`, 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 <os>-<arch>, 'all' for every slot, or 'none'."
options:
- none
- all
- alpine-322-amd64
- alpine-322-arm64
- alpine-323-amd64
- alpine-323-arm64
- alpine-324-amd64
- alpine-324-arm64
- redhat-8-amd64
- redhat-8-arm64
- redhat-9-amd64
- redhat-9-arm64
- redhat-10-amd64
- redhat-10-arm64
- ubuntu-2204-amd64
- ubuntu-2204-arm64
- ubuntu-2404-amd64
- ubuntu-2404-arm64
- ubuntu-2604-amd64
- ubuntu-2604-arm64
default: none
when:
- event: manual
evaluate: 'reindex == "all" || reindex == "${OS}-${ARCH}"'
skip_clone: true
labels:
group: rpkgs-${ARCH}
matrix:
include:
- OS: alpine-322
ARCH: amd64
R_VERSION: 4.5.3
IMG: alpine:3.22
- OS: alpine-322
ARCH: arm64
R_VERSION: 4.5.3
IMG: alpine:3.22
- OS: alpine-323
ARCH: amd64
R_VERSION: 4.5.3
IMG: alpine:3.23
- OS: alpine-323
ARCH: arm64
R_VERSION: 4.5.3
IMG: alpine:3.23
- OS: alpine-324
ARCH: amd64
R_VERSION: 4.5.3
IMG: alpine:3.24
- OS: alpine-324
ARCH: arm64
R_VERSION: 4.5.3
IMG: alpine:3.24
- OS: redhat-8
ARCH: amd64
R_VERSION: 4.4.3
IMG: redhat:8
- OS: redhat-8
ARCH: arm64
R_VERSION: 4.4.3
IMG: redhat:8
- OS: redhat-9
ARCH: amd64
R_VERSION: 4.4.3
IMG: redhat:9
- OS: redhat-9
ARCH: arm64
R_VERSION: 4.4.3
IMG: redhat:9
- OS: redhat-10
ARCH: amd64
R_VERSION: 4.5.3
IMG: redhat:10
- OS: redhat-10
ARCH: arm64
R_VERSION: 4.5.3
IMG: redhat:10
- OS: ubuntu-2204
ARCH: amd64
R_VERSION: 4.4.3
IMG: ubuntu:jammy
- OS: ubuntu-2204
ARCH: arm64
R_VERSION: 4.4.3
IMG: ubuntu:jammy
- OS: ubuntu-2404
ARCH: amd64
R_VERSION: 4.4.3
IMG: ubuntu:noble
- OS: ubuntu-2404
ARCH: arm64
R_VERSION: 4.4.3
IMG: ubuntu:noble
- OS: ubuntu-2604
ARCH: amd64
R_VERSION: 4.4.3
IMG: ubuntu:resolute
- OS: ubuntu-2604
ARCH: arm64
R_VERSION: 4.4.3
IMG: ubuntu:resolute
steps:
- name: '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
REPO_RO_TOKEN:
from_secret: REPO_RO_TOKEN
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]

View file

@ -175,7 +175,12 @@ steps:
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.
BUNNY_PULLZONES: '3857050 cran.allianceswisspass.devxy.io'
# 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.

127
cdn.tf
View file

@ -52,6 +52,26 @@
### 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 <name>.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
# <arch>/<os> 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.
@ -69,9 +89,26 @@ resource "bunnynet_compute_script" "rpkgs_router" {
# 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 = "UNION_SLOTS"
default_value = ""
name = "KNOWN_MINORS"
default_value = join(",", local.rpkgs_supported_minors)
required = false
}
@ -147,6 +184,92 @@ resource "bunnynet_pullzone_hostname" "cran_rpkgs_com" {
tls_enabled = true
}
### Staging zone for edge-router changes
# Every published <arch>/<os> 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.

View file

@ -17,6 +17,8 @@ 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 =
@ -96,6 +98,33 @@ Deno.test('rpkgs-router', async (t) => {
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);
@ -146,18 +175,12 @@ Deno.test('rpkgs-router', async (t) => {
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',
);
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',
);
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 () => {

View file

@ -27,7 +27,17 @@ 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']);
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 ("<arch>/<os>", comma separated) whose per-minor index is a union. */
const UNION_SLOTS = new Set(
@ -37,6 +47,21 @@ const UNION_SLOTS = new Set(
.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),
);
/** `/<arch>/<os>/latest/src/contrib[/<rest>]` */
const SLOT_PATH_REGEX = /^\/(amd64|arm64)\/([a-z0-9._-]+)\/latest\/src\/contrib\/?(.*)$/;
@ -96,6 +121,18 @@ 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);
@ -177,8 +214,8 @@ function parseMacUserAgent(userAgent: string): { os: string; arch: string; rver:
* 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 known; otherwise the flat path,
* which is what every client sees today.
* 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`;
@ -188,7 +225,7 @@ function contribPath(slot: string, rest: string, userAgent: string): string {
}
const rMinor = extractRMinor(userAgent);
return rMinor ? `/${slot}/latest/src/contrib/${rMinor}/${rest}` : flat;
return rMinor && KNOWN_MINORS.has(rMinor) ? `/${slot}/latest/src/contrib/${rMinor}/${rest}` : flat;
}
BunnySDK.net.http
@ -224,6 +261,16 @@ BunnySDK.net.http
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);
@ -238,6 +285,10 @@ BunnySDK.net.http
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)}`));
}

View file

@ -110,10 +110,25 @@ 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,
"SELECT name, tag FROM single_builds WHERE platform = $1 AND arch = $2",
params = list(platform, arch)
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)
)
DBI::dbDisconnect(con)
before <- nrow(chunk)
@ -121,8 +136,9 @@ 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",
"Skipped %d package versions already attempted under R %s; %d remaining for this job",
before - nrow(chunk),
r_minor,
nrow(chunk)
)

View file

@ -68,9 +68,36 @@ 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/<pkg>/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,
Version = as.character(cran_release$Version)
Package = cran_release$Package[!too_recent],
Version = as.character(cran_release$Version[!too_recent])
)
pkgs_to_build <- unique(rbind(archive_versions, release_versions, fill = TRUE))

View file

@ -47,12 +47,31 @@ resolve_zone_id() {
fi
response_file=$(mktemp)
curl -sS -o "${response_file}" \
-H "AccessKey: ${api_key}" \
"https://api.bunny.net/pullzone"
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}" \
'(.Items // .)[] | select(any(.Hostnames[]?; .Value == $hostname)) | .Id' \
'(if type == "object" then (.Items // []) else . end)[]
| select(any(.Hostnames[]?; .Value == $hostname))
| .Id' \
"${response_file}"
)
rm -f "${response_file}"
@ -62,6 +81,12 @@ resolve_zone_id() {
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}"
}

434
scripts/verify-r-minor-routing.sh Executable file
View file

@ -0,0 +1,434 @@
#!/usr/bin/env bash
#
# Verify per-R-minor index routing for cran.rpkgs.com across every published
# <arch>/<os> slot.
#
# The edge router (edge/rpkgs-router.ts) rewrites PACKAGES* requests to
# `contrib/<x.y>/` 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
}
# "<steered> <source-fallbacks>" 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 <minor> 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
}
# "<Package> <Path>" 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