## Summary Adds the curated **patch registry** and wiring that drives bincraft's new package-patching mechanism (see bincraft PR `feat/package-patching`). Lets specific packages be patched (env/configure/Makevars overrides or source diffs) before pak installs them — including as transitive dependencies — so compiler-/OS-specific failures like RcppParallel's bundled TBB stop cascading. ## What's included - `local/patches/registry.json` — initial entry: RcppParallel with `RCPP_PARALLEL_USE_TBB=0` for alpine / ubuntu-2604, plus `local/patches/README.md` schema docs. - `local/validate-patches.R` — validates schema, referenced patch files, and ambiguous overlaps; clean failure + exit 1 (no stacktrace). - `.pre-commit-config.yaml` — a `validate-patches` hook (re-runs when the registry or the validator changes). - `local/build-one.R` / `local/build-all.R` — pass `patches = "local/patches"` to `bincraft::build_binary_package()`. - `specs/2026-06-30-package-patching-design.md` and `plans/2026-06-30-package-patching-implementation.md`. ## ⚠️ Merge ordering (blocker) This PR adds a `patches = ...` argument to `build_binary_package()` calls. The `.crow/*.yaml` workflows currently pin bincraft **v4.2.3**, which does not accept that argument — CI will error with `unused argument (patches=...)` until: 1. bincraft **v4.3.0** is released (PR `feat/package-patching`), and 2. the pin is bumped in `.crow/build-all-versions-install-deps.yaml`, `.crow/build-all-versions.yaml`, and `.crow/process-updates.yaml`. The `.crow` pin bump will be added to this PR once bincraft v4.3.0 is tagged. Do not merge before then. Reviewed-on: #103
9.4 KiB
Design: Dynamic per-package patching during binary builds
Date: 2026-06-30 Status: Approved (pending spec review)
Problem
Some CRAN packages fail to compile on specific build platforms due to compiler- or OS-specific issues that have nothing to do with the package being built.
The canonical example is RcppParallel: its bundled Intel TBB sources fail to build on musl (Alpine) and on newer OS/compiler combinations.
Observed failure on ubuntu-2604 ("resolute") with g++ 15.2.0:
../build/common.inc:74: *** "" is not supported. Add build/.inc file with os-specific settings . Stop.
make: *** [Makevars:163: tbb] Error 2
ERROR: compilation failed for package 'RcppParallel'
Because RcppParallel is a dependency of many packages, a single such failure cascades: every dependent package (e.g. rts2) also fails, even though nothing is wrong with the dependent itself.
Today there is no way to intervene.
A package can only be excluded (local/excluded-packages.json), which is all-or-nothing and does not help dependents.
Goal
Allow a curated set of packages to be "patched" — via lightweight build-time overrides or, when necessary, real source diffs — before they are installed, whether the package is a direct build target or a transitive dependency pulled in by pak.
Key constraint that drives the design
When RcppParallel fails here, it is being installed as a transitive dependency by pak, inside bincraft::build_binary_package().
pak downloads, configures, and compiles it in one subprocess; this repo never touches that source.
For a fix to reach a dependency-of-a-dependency, the fixed package must be visible to pak itself, where pak's repositories/sources are configured — which is inside bincraft.
Decisions taken during brainstorming:
- Mechanism lives in
bincraft(the engine), because only there can transitive deps be influenced. - Patch tiers: both, env-overrides first. Support cheap per-package env vars / configure args / Makevars (version-independent) and true source diffs (version-pinned), preferring the lightweight override.
- Registry data lives in this repo (
build-cran-binaries) and is passed intobincraft, keepingbincraftas pure mechanism and the frequently-changing policy data with operational config.
Approaches considered
| Approach | How pak sees the fix | Verdict |
|---|---|---|
A. Patched source repo — drop patched .tar.gz source into a local repo, prepend it |
pak recompiles from your source | Simple, but env-tier overrides leak globally (one subprocess builds everything) and the dep recompiles on every dependent build |
| B. Pre-built patched binary repo (chosen) | pak installs a ready binary by repo priority | Per-package scoping is free; no recompile; the binary is a cacheable/uploadable artifact that fits the existing system |
| C. pkgdepends per-build hook | intercept each build | No clean per-package pre-compile hook exists; fragile |
Chosen: B.
Architecture
Registry (this repo)
local/patches/
registry.json # the manifest
RcppParallel/
fix.patch # optional source diff, referenced by an entry
registry.json is an array of entries:
[
{
"package": "RcppParallel",
"versions": "*",
"platforms": ["alpine", "ubuntu-2604"],
"env": { "RCPP_PARALLEL_USE_TBB": "0" },
"configure_args": [],
"makevars": {},
"patch": null,
"reason": "bundled TBB fails to build on musl / newer compilers"
}
]
Field semantics:
package(string, required): CRAN package name.versions(string, required):"*"for any, a constraint such as">=5.1.0", or an exact version"5.1.11-2". Env-tier fixes are typically"*"; source diffs are normally exact or lower-bounded because a diff is pinned to the source it was generated against.platforms(array of strings, required): matched against the running build's platform tokens — distro family (alpine,ubuntu,redhat), codename (ubuntu-2604,alpine-324), and arch (amd64,arm64). An entry matches if any listed token matches any build token.["*"]matches all platforms.env(object, optional): environment variables exported only for this package's isolated build.configure_args(array, optional): passed as--configure-argsto the isolated build.makevars(object, optional): key/value pairs written into a package-local Makevars for the isolated build.patch(string or null, optional): path (relative tolocal/patches/) to a unified diff applied to the unpacked CRAN source before building.reason(string, required): human explanation, surfaced in logs and metadata.
A fix is any combination of env, configure_args, makevars, and patch.
"Env-first" is an authoring guideline (prefer the lightweight override) and an ordering of effort, not a runtime branch — all present fields are applied together for the isolated build.
Flow (inside bincraft, around existing pak resolution)
- Resolve the dependency set (dry-run) to learn the concrete versions
pakwill install. Reuse bincraft's existing resolution where possible (e.g. apkgdependsproposal:$resolve()→ inspect resolution → ... →$solve()/$install()after the local repo is prepended). - For each resolved package that matches a registry entry (name +
versions+platforms): obtain a patched binary for the exactversion × platform × arch × R-minor:- Cache hit (local
/mnt/cache/patched-binaries/or S3): fetch it into the local repo. - Cache miss: download the CRAN source for that version, apply the source
patch(if any) to the unpacked tree, build the binary in isolation withenv/configure_args/makevarsapplied, then place the binary in the local repo and write it to the cache (and S3 if uploading is enabled).
- Cache hit (local
- Prepend the local binary repo (
file://…) topak's repo list, and regenerate itsPACKAGESindex. - Run the normal install.
pakresolves the patched binary for the matched package — direct or transitive — because it wins on repo priority for an equal version, and installs it without recompiling.
Caching (essential)
RcppParallel is a dependency of dozens of packages; without caching the fix would be rebuilt on every dependent build.
Patched binaries are keyed by:
<package>_<version>_<platform>_<arch>_<rminor>_<patchhash>
patchhash is a hash of the normalized registry entry plus the referenced diff file contents.
Editing a patch therefore changes the hash and auto-invalidates stale cached binaries.
- Local cache:
/mnt/cache/patched-binaries/. - Optional S3 cache for cross-build reuse: a dedicated
…/patched/slot under the existing arch/codename structure, mirroring how normal binaries are stored.
S3 upload
Patched binaries are uploaded to S3 (in addition to the local cache) so they are reused across CI jobs and machines, not just within one container.
They live in a separate patched/ slot and are not published into the user-facing src/contrib index — they are an internal build accelerator, not a distributed artifact.
Error handling
- Source diff fails to apply (CRAN moved past the pinned version): log a clear warning, skip that entry, and proceed. The package builds unpatched (status quo) and may fail. The skipped/failed-to-apply patch is surfaced in build metadata.
- Pre-build of the patched binary fails: log a warning, skip, proceed.
- No version or platform match: skip silently (the entry simply does not apply to this build).
- Overlapping entries for one package: the most specific entry wins (a concrete
platforms/versionsbeats"*"). Genuine ambiguity (two equally specific, conflicting entries) is a validation error reported before the build.
Observability
- One log line per applied patch, e.g.:
Applying patch to RcppParallel 5.1.11-2 [env: RCPP_PARALLEL_USE_TBB=0]: bundled TBB fails on musl / newer compilers - The set of applied patches (package, version,
patchhash) is recorded in the Postgres build-metadata row for the build, so it is queryable later.
Testing
- Unit (registry): parsing and matching — version constraints, platform token matching, precedence/specificity, and detection of ambiguous overlaps.
- Unit (cache key):
patchhashchanges when the entry or diff changes; is stable otherwise. - Integration:
RcppParallelonresolute(and/or Alpine) fails to build without a registry entry and succeeds with one; a dependent package such asrts2succeeds once the dependency is patched. - Failure path: an entry pinned to an old version against a newer CRAN release → graceful skip with a warning, build continues.
Out of scope
- Shipping a default registry inside
bincraft(registry is repo-local for now; a baseline-in-engine + repo-override model can come later if needed). - Publishing patched binaries into the public
src/contribindex. - Automatic detection of which packages need patches — entries are curated by hand.
Split of work
- bincraft: the mechanism — registry ingestion, resolution hook, isolated patched-binary build, caching/upload, local-repo prepend, logging, metadata recording.
A new
patchesargument onbuild_binary_package(). - build-cran-binaries (this repo): the
local/patches/registry and diffs, passingpatches = "local/patches"throughbuild-one.R/build-all.R, and documentation.