Skip to contents

bincraft (development version)

  • bincraft no longer depends on the pat-s/cranlike@s3 and pat-s/desc@description-from-remote forks, so DESCRIPTION has no Remotes: left (#33). upload_package_index() now builds the index records itself and uses the slot’s published PACKAGES.rds as its cache instead of PACKAGES.db. A record is reused while its tarball is listed with the same ETag; a new or replaced tarball is read again and stamped with Built, where cranlike only updated the MD5 of a replaced one. DESCRIPTION files are fetched from the CRAN mirror on GitHub in parallel, and a package the mirror does not have is read from its own tarball instead of being left out of the index on every run. PACKAGES.db is no longer written or uploaded, the stale copy in each slot can be deleted, and the index files are written to a temporary directory instead of the working directory.

  • upload_package_index() is faster: the slot is listed once instead of three times, and PACKAGES and PACKAGES.gz are written without refolding every value through strwrap(), which took over a minute per 25k-record slot. The files read back to the same records; only their line wrapping changes.

  • add_to_package_index() is now defunct and errors; use upload_package_index().

  • bincraft no longer imports data.table, dplyr, gert, gh, lubridate or purrr (#29). This drops 16 packages from a fresh install and the libgit2 system requirement; git itself is still needed and is now listed under SystemRequirements. Date ranges are now plain Date pairs: get_updated_cran_packages(), get_new_cran_packages(), get_removed_cran_packages(), process_cran_updates() and get_r_minor_sensitive_packages() default to c(Sys.Date() - n, Sys.Date()) and accept a single Date, a c(start, end) pair or, as before, a lubridate::interval(). query_metadata_table() still returns a lazy dplyr::tbl(), but dplyr and dbplyr are now suggested packages it asks for when they are missing. GitHub tags are fetched through httr2 instead of gh; a token is read from GITHUB_PAT or GITHUB_TOKEN, but no longer from the git credential store. Cloning sets advice.detachedHead=false for the clone only, instead of writing it to the global git config. Retries use an internal insistently(); a request that keeps failing now reports the last error alongside the attempt count.

  • Connections to the metadata database can now verify the server certificate (#225). libpq looks for the root certificate of sslmode = "verify-ca" or "verify-full" in ~/.postgresql/root.crt, not the system trust store, so verifying never worked in the build images. Every connection now passes the system CA bundle as sslrootcert when the mode verifies, unless PGSSLROOTCERT is set. query_metadata_table(), list_metadata_tables() and get_missing_pkgs_db(), which connect to the devxy database with a hardcoded mode, now use verify-full instead of require.

  • upload_package_index() no longer defaults to the devxy production database (#211). metadata_db_host, metadata_db_name, metadata_db_port, metadata_db_user and metadata_db_password now default to NULL instead of r-binaries.devxy.io and the PGPASS environment variable. A per-minor call (r_minor set) now stops before touching S3 unless it is given a host, a database name and a non-empty password, and the error names the missing arguments. The generic index never reads the database, so a call without r_minor is unaffected.

  • A failed dependency install or build start is no longer written to the metadata table when store_build_metadata = FALSE (#36). Both paths decided whether to record the failure by whether metadata_db_host was set, but the host is also set for the ABI cache, so a run with storage disabled still wrote error rows. They now follow store_build_metadata, like every other build path.

  • fetch_tags_via_ls_remote() now keeps only refs/tags/ lines, so a branch or HEAD in the git ls-remote output can no longer come back as a tag.

  • upload_source_tarball() no longer replaces a published binary with CRAN’s source. It uploaded with overwrite = TRUE without looking at the key, relying on its only internal caller to have checked first, but it is exported. It now asks remote_object_state() and skips the upload when a binary (or an object of unknown state) is there, while still replacing an earlier source fallback. It now returns TRUE only when it uploaded, and FALSE when the download failed or a binary was kept, where it used to return TRUE for a failed download.

  • Architecture detection and binary tarball naming now work on every machine R runs on (#210). The tarball R CMD INSTALL --build writes is now located by its real name, <pkg>_<version>_R_<R_PLATFORM>.tar.gz, instead of being reassembled from the machine and distribution, which failed on ppc64le, s390x and riscv64 and needed a scan of the output directory to spot an EPEL build of R on RHEL. set_bin_path(), upload_package_index(), archive_package() and build_binary_package() now share one architecture lookup, which fails with a clear message on an unsupported machine instead of “object ‘arch’ not found”. set_bin_path() gains an arch argument, and build_binary_package() now honours a supplied arch instead of overwriting it with the machine’s. build_binary_package() no longer creates an empty src/contrib/Archive/Archive in the output root. detect_linux_codename() now returns fedora<version> on Fedora instead of NULL. The unused cran_mirror_version() and get_system_architecture_info() are removed.

  • An older tag whose archived copy is a CRAN source fallback is now rebuilt instead of being skipped by the pre-build filter (#208). list_archived_packages() counted every object under Archive/<pkg>/ as present, so a failed build of an older version, once archived, was never retried. It now reads the archive with s3_dir_info() and leaves out objects whose MD5 or exact size matches CRAN’s, which costs no request beyond the listing; an object whose size cannot be read, or a CRAN that cannot be reached, still counts as present. check_for_binary() now delegates to remote_object_state(), so it recognises an archived version’s source fallback by size as well as a current one’s by MD5, and it also looks in Archive/<pkg>/ when the root holds nothing. Archiving already moved a rebuilt binary over an archived source fallback; it now refuses the reverse and deletes a source fallback at the root rather than move it over an archived binary of the same version.

  • check_s3_packages() no longer rebuilds every tag of a repository whose tags carry a v prefix (#206). The slow path passed the latest tag unchanged to check_s3_root_package() and get_all_s3_packages(), so for v2.0.0 they looked for pkg_v2.0.0.tar.gz, which is never published, and every run rebuilt binaries that already existed. Both lookups now use the artifact version (2.0.0), while the refs handed back for cloning keep their v.

  • install_pkg_sys_deps() now pre-installs only the patched binaries in the dependency tree of the package being built (#209). install_patched_into_library() installed every patched binary in the registry, so each build paid for patches it never used. The tree is resolved recursively over Depends, Imports and LinkingTo from the session-cached CRAN index; when it cannot be resolved completely, every patched binary is installed as before and an INFO message says so. prepare_patched_repo() now skips the target package’s own entry, which patches its source instead, and caches each package’s latest CRAN version for the session, so pkgsearch is queried once per patched package rather than once per build. A malformed version constraint in the target’s own entry now logs a warning that it is built unpatched instead of being ignored silently.

  • get_updated_cran_packages() and get_removed_cran_packages() no longer silently drop packages from a long interval or return a package twice (#177). Both read a single page of pkgsearch::cran_events() (2000 releases or 300 archivals) and never paged with from, so anything older than that page was cut off without warning. They now page until the feed reaches past the start of date_interval, and limit is the page size; after 50 pages they stop and log a warning naming the date they reached. A package released twice in the interval produced two rows, so the older version could be built after the newer one; only the newest event per package is kept now.

  • fetch_cran_versions() now logs a warning when Meta/archive.rds cannot be downloaded. The failure was swallowed, so with tag_limit > 1 it silently returned only the current version (#177).

  • A single failed read of CRAN’s index no longer disables source-fallback detection for the rest of the session, and a source fallback for an archived version is now recognised before a build or upload (#171). cran_source_md5() and cran_archive_size() cached the empty table from a failed read, so after one network blip every remote_object_state() answered “binary”: no source fallback was replaced and clear_built_for_sources() left Built on all of them. A failed read is now retried after five minutes (or on refresh = TRUE) instead of being kept, and only a successful read is cached. remote_object_state() only compared MD5s against CRAN’s current releases, so a source fallback of an older version always read as a binary. It now also matches the object’s exact size against CRAN’s archive index, as clear_built_for_sources() already does, and an unknown size or an unreachable CRAN still answers “binary”.

  • A batch of small fixes from the package review (#178). build_binary_package(), filter_tags() and process_tag_filtering() no longer error on a vector or NA tag, and process_tag_filtering() now resolves a NULL tag instead of returning it. move_and_rename_tarball() warns when the rename fails instead of ignoring it, and lists the output directory only at DEBUG rather than at INFO. Log messages keep their apostrophes and backticks instead of printing '', and {.function} tags, which cli does not know and printed literally, are now {.fun}. parse_bytes() accepts vectors and short units such as "1K", and errors on a unit it cannot parse instead of returning NA. upload_package_index() retries each PACKAGES* upload, uploads PACKAGES last, and logs a package count based on the tarballs in the listing rather than the listing size minus 5. install_pkg_sys_deps() no longer fails with “promise already under evaluation” when called without platform. process_unarchived_pkgs() now honours workers, get_missing_pkgs_db() takes the database connection settings as arguments, and detect_linux_codename() recognises Debian, whose os-release has no ID_LIKE. Arguments passed to git, uvr and R CMD INSTALL are shell-quoted, so paths with spaces no longer break them.

  • Metadata DB connections, clone directories and patch Makevars files are now cleaned up on every exit path (#174). remove_from_metadata() never disconnected, so pruning N removed packages in process_cran_updates() opened N connections, and store_build_metadata(), filter_packages_with_errors(), list_metadata_tables() and get_missing_pkgs_db() leaked theirs on error. build_single_tag() left the clone behind when dependency installation failed, which filled /tmp during large rebuilds, and the R_MAKEVARS_USER file written for a registry patch was never removed. An unsupported metadata_db_type now fails with a clear error instead of “object ‘driver’ not found”, and build_binary_package() now passes metadata_db_type through to the per-tag builds.

  • init_repo() is now exported and works. It reports a missing S3 bucket instead of silently continuing, fails when the metadata DB is unreachable, creates the metadata table with the columns store_build_metadata() writes (it previously failed with ‘argument “fields” is missing’), and disconnects afterwards. The bucket is not created; create it with your S3 provider first.

  • add_to_package_index() is deprecated in favour of upload_package_index() and now warns when called (#176). It never worked: it downloaded the remote PACKAGES only when that file was missing, matched tarballs with the regex "<pkg>*" (so "sf" also picked up s_1.0.tar.gz and stars_1.0.tar.gz), and never uploaded the index it built. Nothing in bincraft or the other rpkgs repositories calls it, so it is deprecated rather than repaired. It also now derives the architecture via detect_arch() and fails with a clear message on an unrecognised machine instead of “object ‘arch’ not found”.

  • A per-minor upload_package_index() now refuses to publish when it cannot read the ABI lookups, instead of publishing an index that carries known-broken generic binaries (#163). flat_safety_unsafe_set(), abi_cache_risky_packages() and risky_packages_across_minors() returned an empty set on any database or S3 error, which reads as “nothing is unsafe”: with the database unreachable, Rcpp’s 4.5-built generic binary, recorded as unable to load under 4.4, went back into every 4.4 index. They now return NULL when the lookup fails, and the per-minor index build aborts on it, leaving the previously published index in place as it already does for an unreadable generic index. The ABI lookups and flat_safety_store() no longer default to the r-binaries.devxy.io database with PGPASS. upload_single_binary() gains metadata_db_* arguments, which build_binary_package() and process_cran_updates() now pass through, so the flat-safety verdict lands in the database the build was configured with, and is not recorded at all without one. upload_package_index() gains the same arguments; their defaults are the previous production values with the password from PGPASS, so existing pipelines keep working unchanged. A connection with an empty password is treated as not configured instead of being attempted, and an authentication failure is no longer retried, so an unset PGPASS no longer stalls each lookup for about five minutes.

  • Pruning packages removed from CRAN now also deletes their per-minor copies under contrib/<r_minor>/ (#175). process_removed_packages() listed only the generic slot, matched tarballs with a regex in which a . in the package name was a wildcard (so removing data.table would also delete dataXtable), and failed with “object ‘arch’ not found” on an unrecognised machine. It now lists every per-minor slot, matches the package name as a fixed prefix, and stops with a clear error when detect_arch() cannot determine the architecture. The affected slots are not re-indexed automatically, since upload_package_index() stamps Built from the running R; a warning names each slot whose PACKAGES index must be republished.

  • Parallel builds no longer install dependencies concurrently (#173). With more than one future worker, every tag’s worker ran uvr sync --install-system-deps against the same library at the same time, so apt-get/apk/dnf collided on the package manager lock and the workers raced on the shared library. install_pkg_sys_deps() now holds a lock directory (.bincraft-deps.lock in the build library) for each install attempt, so workers take turns while builds themselves still run in parallel. The lock is released between retry attempts, and a lock left behind by a killed worker is reclaimed.

  • A malformed versions constraint in one patch entry no longer disables every dependency patch (#172). resolve_patch_version() let the “invalid version specification” error escape, so prepare_patched_repo() aborted and install_pkg_sys_deps() built every package without any patched dependency. The error now skips only that entry with a warning naming the constraint, and versions accepts a comma-separated range such as ">=1.0, <2.0", all clauses of which must hold. Patched binaries already installed from the same tarball are no longer reinstalled with R CMD INSTALL on every build and retry. The dependency pre-install of an isolated patched build runs uvr on a copy of the DESCRIPTION, so uvr.toml, uvr.lock and .r-version no longer land in the patched package’s source tree.

  • A package whose build failed is no longer rebuilt on every run despite its recorded error (#169). check_s3_packages() returned before the metadata error filter whenever no binary was published at the root, which is always the case after a failure because the CRAN source fallback counts as absent, so the filter never ran for a single explicit tag. The filter now runs on that path too; force = TRUE remains the way to retry such a package. A build that errored before build_single_tag() could record it (typically a failed clone) is now recorded under the artifact version (5.0.0) rather than the ref (v5.0.0), so the filter finds it, and its clone directory is cleaned up.

  • A dependency installation that fails deterministically is no longer retried for minutes as a “network error” just because its output names a package such as network or networkD3 (#170). classify_uvr_error() matched the bare words network and timeout anywhere in the uvr output, which lists every resolved package, so a failed apk add was retried with backoff before failing anyway. It now matches specific phrases (network is unreachable, network error, timed out, …) instead. retry_with_backoff() also rethrows the error message unchanged, so build errors stored in the metadata database no longer contain doubled {{ }} braces (the log helpers already escape braces for cli), and each retry now logs the error message, not just its type.

  • process_cran_updates() now honours process_new, process_updated, codename, local_output_dir_root and local_clone_dir (#166). The updated and new package sets were always fetched and built together, so process_new = FALSE still built new packages and vice versa, and the three path/codename arguments never reached build_binary_package(). A caller-supplied codename therefore applied to removals while builds auto-detected theirs, so the two could target different S3 slots, and builds always wrote to "." and cloned into /tmp. Builds now clone into local_clone_dir, which defaults to tempdir(). With both flags FALSE nothing is fetched from CRAN at all. cran_new is now queried once per run instead of twice: get_updated_cran_packages() gains a new_packages argument to reuse an already fetched set. The unused package_name and tag arguments are deprecated and emit a warning when supplied.

  • process_cran_updates() no longer aborts when called with its default interval (#165). The default was lubridate::today(), a Date, which get_updated_cran_packages() and get_removed_cran_packages() fed straight into %within% (“No %within% method with signature a = Date, b = Date”), while get_new_cran_packages() retried the failing int_start() for minutes inside insistently() and then returned nothing. The default is now lubridate::interval(today() - 1L, today()), and all four functions coerce a single Date into the one-day interval covering it. Any other value, or an interval that starts after it ends, now fails fast with a clear error instead of silently yielding an empty result. The process_cran_updates() example passed exactly such a reversed interval and is fixed.

  • r_minor_detection = "issue" (and the legacy filter_r_minor_sensitive = TRUE) no longer aborts process_cran_updates() with “no applicable method for ‘filter’” (#164). get_r_minor_sensitive_packages() returned a character vector of names, or invisible(TRUE) when nothing matched, where the caller expected the name/version data.frame of packages to build. When interval, updated_packages or new_packages is given it now returns the matching rows of that data.frame, with zero rows when none match; called without them it still returns the package names from the issue. Issue lines are trimmed and Markdown list bullets stripped, so CRLF line endings and - pkg lists match too. An unknown r_minor_packages_forge_type or a missing r_minor_packages_issue_url now fails with a clear error instead of “object ‘body_vector’ not found” or an httr2 type error.

  • A failed build’s source fallback now checks and uploads the version that was actually built, and only for CRAN packages (#159). handle_post_build_actions() never passed the built tag on, so check_for_binary() and upload_source_tarball() each asked the github.com/cran mirror for the version, two GitHub REST calls per package. A package not yet on the mirror got no fallback at all, a lagging mirror pointed the check at the previous version’s tarball, and a non-CRAN package sharing a CRAN package’s name could have CRAN’s source published in its place. upload_source_tarball() gains a version argument and check_for_binary() now honours its existing one; the default "latest" resolves the current version from CRAN’s own metadata instead of the mirror.

  • A package classified as R-minor sensitive is now built into its per-minor slot even when a binary of the same version already sits in the flat slot (#156). check_s3_packages() correctly found the per-minor slot empty and scheduled the build, but the skip check in build_single_tag() only looked at the flat slot and the flat Archive/, found the old binary, and skipped, so the per-minor binary was never built. check_build_skip_conditions() gains an is_r_minor_sensitive argument, and it and check_s3_root_package() now derive their keys from one helper, remote_binary_keys().

  • A package version listed in s3_package_cache is no longer skipped when this slot holds only its CRAN source fallback, or holds nothing and the name came from another R minor’s directory or an Archive/ (#155). The fast path in check_s3_packages() compared pkg_ver.tar.gz against the cache’s basenames with a plain %in%, so it had the source-fallback blind spot already fixed in check_s3_root_package(), and a recursive listing reduced to basenames let 4.5/pkg_1.0.tar.gz satisfy a 4.6 or flat run. A cache hit is now only a candidate: the object at this run’s slot (its root, then its Archive/<pkg>/) is checked with remote_object_state(), and only a binary counts as built. Versions missing from the cache still cost no S3 call.

  • A package version whose ABI classification failed is now classified again on the next run instead of being pinned to per-minor builds forever (#161). classify_r_minor_sensitive() falls back to TRUE when the CRAN source download or the classification errors, and it stored that fallback in the ABI cache as if it were a real verdict. Every later run got a cache hit, and abi_cache_risky_packages() fed the version into each per-minor index build as risky, so a single transient CRAN outage dropped its generic records for good. Only computed verdicts are cached now, and a failed cache lookup is logged at debug level instead of being swallowed silently.

  • An ABI-risky package whose per-minor build fell back to source no longer disappears from that minor’s index (#160). The per-minor union let a generic binary replace the source fallback whenever the generic record’s Built stamp named the target minor, but that stamp is written slot-wide by the indexing R and says nothing about the object. The risky-package drop then removed the generic record as well, so the package was missing entirely, and with no risky signal the promoted binary could be one built under another minor. union_index_records() now promotes a generic binary only when its object has a recorded verdict that it loads under this minor, which is the same test the carried-record drop applies, so a promoted record is never dropped afterwards.

  • A shared object that nm cannot read now leaves the ABI verdict undecided instead of marking the binary portable (#162). system2(stdout = TRUE) does not raise on a non-zero exit but returns character(0) with a status attribute, which nm_symbols() parsed as “references no symbols”. A non-ELF or unreadable .so therefore made shared_object_abi_verdict() report the package inspected and not r-minor-sensitive, so the binary went to the generic slot and was recorded safe. nm_symbols() now returns NULL on a failing exit status, and the source classifier’s decision stands as documented.

  • Packages with v-prefixed tags (v5.0.0) build again when only some of their versions are missing from the bucket (#154). check_s3_packages() recovered the tags to build by parsing them back out of the artifact names, which carry the version without the v, so the clone asked for --branch=5.0.0 and failed with “Remote branch not found”. It now returns the original refs, matched to the missing artifacts by position.

  • archive_package() now keeps the highest version of a package in the root and archives the rest, so a freshly built binary is no longer moved to Archive/ behind an older one (#158). The version to keep was read from the latest commit message on github.com/cran/<pkg>, without any package_version comparison, so whenever that mirror lagged CRAN the stale version stayed in the root index and the new binary was archived. find_old_versions() now parses the versions from the file names and compares them with package_version(), which also drops a GitHub API call per archived package and the pkgsearch::cran_package_history() fallback.

  • archive_package() now archives old versions of ABI-sensitive packages (#157). With is_r_minor_sensitive = TRUE it listed contrib/TRUE instead of contrib/<minor>, found no tarballs, and always skipped with “only one package versions exists”. Old versions therefore piled up in contrib/<minor>/, the per-minor Archive/ and Meta/archive.rds stayed empty, and only empty Archive/<pkg> prefixes were created. It now lists the slot of the running R minor, the same one the archive paths are built from.

  • Setting BINCRAFT_LOG_LEVEL in uppercase (e.g. DEBUG) no longer makes the package fail to load (#168). .onLoad() validated the level case-insensitively but passed the original string to lgr, which only accepts lowercase level names.

  • The source fallback and archiving now act on the slot the binary was actually uploaded to (#152). upload_single_binary() overrides the caller’s is_r_minor_sensitive with the result of its symbol inspection, but returned only TRUE/FALSE, so handle_post_build_actions() still checked, published and archived against the caller’s guess. A package flagged as sensitive but inspected as safe had its binary written to the flat slot and the CRAN source published into <minor>/, where the per-minor record shadowed the binary for clients on that minor and was never replaced, because the next run found the flat binary and skipped the package. upload_single_binary() still returns TRUE/FALSE, now with an is_r_minor_sensitive attribute naming the effective slot, and build_single_tag() passes it on the same way. isTRUE() and if () on the return value behave as before, but identical(x, TRUE) no longer holds after an upload.

  • Regenerating the documentation no longer drops set_bin_path() from the exports (#167). detect_arch() and valid_patched_binary() had been inserted between set_bin_path()’s and prepare_patched_repo()’s roxygen blocks and their functions, so the next devtools::document() merged the blocks and the regenerated NAMESPACE no longer exported set_bin_path(). Both helpers now sit above the blocks they had split, prepare_patched_repo()’s help no longer describes the pak-era file:// repo and pkgcache snapshot, and the man/ pages that had drifted from their sources are regenerated.

  • build_binary_package() with a custom local_output_dir_root now uploads the binary it built instead of recording a successful build for a binary that never reached S3 (#153). build_single_tag() called upload_single_binary() without the output root, so the upload looked for the tarball under the default ".", and a missing local tarball was reported as a successful upload. The build row was then written as published, check_for_binary() found nothing in S3, and the CRAN source was published in place of the binary. upload_single_binary() gains a binary_output_path argument, which build_single_tag() now passes, and returns FALSE with a warning when the local tarball is missing.

  • Dependency installation now reuses the clone build_single_tag() already made instead of cloning the package a second time from the hardcoded github.com/cran mirror (#150). The second clone ignored source_org_url, so a package from any other organisation failed with “Remote branch … not found” whenever the mirror lacked the tag (every v-prefixed tag, internal packages, versions newer than CRAN), and otherwise resolved dependencies from the CRAN release’s DESCRIPTION rather than the source being built. install_pkg_sys_deps() gains a clone_dir argument to reuse an existing clone and a source_org_url argument for standalone use, which clone_package_repo() now honours too. uvr runs on a copy of the DESCRIPTION, so its uvr.toml, uvr.lock and .r-version never land in the source tree being built.

  • The two remaining existence gates now tell a source fallback from a binary. Fixing check_s3_root_package() let a rebuild get as far as the build step, where a second gate in build_single_tag() skipped it again with “already exists in S3 and force = FALSE”, and a third in upload_single_binary() would then have refused to publish the binary because the source occupied its key. All three now share one helper, remote_object_state(), which answers "absent", "source" or "binary" rather than a bare yes/no, and a source fallback is overwritten rather than left in place. Unknown stays “binary” throughout, so an unreadable ETag or an unreachable CRAN still cannot schedule a rebuild of the repository.

  • The source-fallback detection now actually reads the object’s MD5. remote_object_md5() looked for an ETag column, but s3fs::s3_file_info() snake-cases the head_object response and renames e_tag to etag. Reading a missing column yields NULL rather than an error, so every object came back as “MD5 unknown”, every caller took the conservative “assume it is a binary” branch, and the checks added in 5.1.1 and 5.1.2 were inert. A 13,389-package rebuild of amd64/alpine324 running 5.1.2 still skipped every package with “All packages to be built already exist in the remote bucket”. Both spellings are now accepted, and the regression is covered by tests that feed remote_object_md5() a realistic s3fs return value instead of stubbing it out.

  • The pre-build skip now also tells a source fallback from a binary. check_s3_root_package() decides whether a build runs at all, and it tested only that something occupied the key. A package whose build failed has its CRAN source published under exactly that name, so a rebuild skipped every one of them with “All packages to be built already exist in the remote bucket” – precisely the set a rebuild exists to fix. Observed on arm64/alpine324, where a 13,647-package rebuild skipped every package it was given. The previous release fixed the same blind spot in check_for_binary(), which decides whether to publish a fallback, but not this one, which decides whether to build. An object whose MD5 matches CRAN’s published MD5sum is now reported as absent, and an MD5 that cannot be established still counts as a binary so an outage cannot trigger a full rebuild.

  • A package that failed to build is no longer mistaken for a binary. When a build fails, handle_post_build_actions() publishes the CRAN source tarball in its place so the package stays installable, but check_for_binary() only tested that an object existed at the path. The fallback therefore became permanent: the build was never retried, and the missing-binaries audit never reported it, because the object was there. On amd64/alpine324 that left 13,547 of 24,134 comparable objects (56%) as CRAN sources, all published during the June 2026 bootstrap; amd64/noble sits at 4.6% for comparison. check_for_binary() now compares the object’s MD5 against CRAN’s published MD5sum, which costs no downloads, and reports a source fallback as missing. When the MD5 cannot be established (a multipart ETag, or CRAN unreachable) the object still counts as a binary, so neither can trigger an endless rebuild.

  • check_for_binary() now honours is_r_minor_sensitive. It looked in the generic slot even for per-minor packages, while the source fallback wrote them to the per-minor slot, so it never found an existing per-minor build.

  • The Built stamp is no longer written onto packages served as their CRAN source. The stamp is applied to a whole slot at once, so it landed on the source fallbacks too and advertised them as binaries: uvr then installs one without the system -dev libraries a source build needs, and paquetier files it under a platform it was never built for. upload_package_index() now clears Built on records whose MD5 matches CRAN’s. Archived versions are left alone, since CRAN’s index carries only current releases.

  • A per-minor PACKAGES* index is now republished as a union with the generic slot. A per-minor slot carries only the ABI-sensitive packages, and contrib.url() can only ever address <repos>/src/contrib, so those packages were unreachable from install.packages(): on amd64/alpine324 2,886 packages, curl among them, existed only in the per-minor slot and were absent from the index base R reads. upload_package_index() now merges the generic slot’s records into the per-minor index before uploading it, tagging the per-minor records with Path: <r_minor> so their tarballs still resolve into …/src/contrib/<r_minor>/ while the generic ones resolve into …/src/contrib/. Nothing is copied or duplicated, and a package built for this minor shadows the generic one entirely. An unreadable or empty generic index is an error rather than a partial publish, because replacing a published union with a per-minor-only index would hide most of the repository from every client on that minor.

  • built_stamp() now refuses an unusable platform or R version instead of stamping it. The stamp is written verbatim into every entry of a slot’s PACKAGES index and uvr decides binary-vs-source by matching the triple it carries, so a missing value does not degrade gracefully: it stamped the literal NA, no client matched it, and the whole slot silently reverted to source-only. This is how arm64/alpine321 (22,930 entries, Built: R 4.4.0; NA) and arm64/alpine322 (24,696 entries, Built: R 4.5.0; NA) were written during the one-time re-index on 2026-07-31; both slots need another full re-index to recover. Failing the index write is the cheaper outcome, so an absent, NA, empty or non-scalar platform/r_version is now an error.

  • Dependency names are now written as quoted keys in the generated uvr.toml. R package names may contain dots, and a bare data.table = "*" is a dotted TOML key: it parses as package data with a sub-key table rather than as data.table. Where the parent name is itself a dependency, for example rpart alongside rpart.plot, the parse fails outright with “dotted key rpart attempted to extend non-table type (string)”. Either way uvr lock aborted and the build failed with “Error in installing dependencies for package …”, so every package depending on a dotted name was unbuildable.

  • uvr is now pinned to the R version that runs the build. run_uvr_install() writes a .r-version file into the package clone before uvr lock, so both lock and sync target the session’s R. Previously bincraft told uvr nothing about the R version, and uvr picked the newest R it could find (managed installs first, then system R). On an image carrying a second, newer R, for example a /opt/R/current symlink already moved to 4.6 while the build runs in 4.5, uvr resolved the lockfile for the wrong R minor and uvr sync then refused to install anything: “Refusing to install: uvr is running inside R 4.5 but the project pin/lockfile resolves to R 4.6”. That aborted every dependency install on the affected images.

  • The S3 package index now advertises its binaries via the Built field. upload_package_index() and add_to_package_index() pass a built stamp (build R version plus R.version$platform triple) to cranlike, which otherwise reads metadata from the CRAN source DESCRIPTION and leaves Built empty for every entry. Without it, binary-aware clients such as uvr treat cran.rpkgs.com as source-only and compile every package from source, which fails on Alpine when a system -dev library is missing (e.g. openssl). install.packages() was unaffected because it reads Built: from each tarball’s own DESCRIPTION. Existing indexes need a one-time full rebuild to backfill Built on entries already in the database.

  • Dependencies and their system requirements are now installed with uvr instead of pak during build_binary_package(). uvr reads /etc/os-release, resolves each dependency’s system requirements per distro, and installs them with the distro package manager (apk / dnf / apt-get) in the same pass as the R packages, so a build no longer fails when a dependency needs a system library the image does not ship (for example gert needing libgit2). Patched binaries are installed into the build library first so uvr sync keeps them, and pak is no longer a dependency (#83).

  • Patched binary caches are now validated before reuse and written atomically. prepare_patched_repo() verifies each cached/assembled tarball reads cleanly and carries the package DESCRIPTION plus a shared object (when it ships a libs/ dir) before serving it, and rebuilds instead of reusing a corrupt entry. Fresh binaries are staged to a temp file and renamed into place, so a build killed mid-copy can no longer leave a truncated cache entry that made every dependent build fail with tar: A lone zero block or a missing RcppParallel.so.

  • CRAN package versions are now resolved from CRAN’s own metadata (available.packages() plus Meta/archive.rds) instead of the GitHub REST tags API. The old gh::gh("GET /repos/cran/{pkg}/tags") call counted against the authenticated user’s 5,000 requests/hour REST limit, which the weekly rebuild exhausted (HTTP 403 “API rate limit exceeded for user ID …”). The GitHub API is still used for genuine non-CRAN forge sources.

  • classify_r_minor_sensitive() now downloads the CRAN source tarball (download_cran_source()) instead of cloning github.com/cran, and caches its verdict in the metadata database (table abi_classification, created on first use) keyed on (package, version) plus a signature of the curated ABI lists. Because the r-minor-sensitivity verdict is a property of the package source (independent of OS, arch and R minor), a full rebuild for a new OS release reuses cached verdicts and performs no downloads. The function is now exported and takes metadata_db_* arguments; pass them to enable caching.

bincraft 4.4.7

  • The local patched-binary repo served to pak is now assembled at a stable, content-addressed path under cache_dir instead of a fresh tempfile() per call. Every build_binary_package() resolution in a run passes the same patches/platform/arch/R, so they now resolve to the same file:// repo URL and pkgcache reuses a single _metadata snapshot. Previously each resolution minted a new repo path, so pkgcache wrote a fresh ~70 MB _metadata/patched-<hash> snapshot that never repeated and never evicted, growing unboundedly during full-platform builds (up to ~165 GB observed). A fully assembled repo is now also reused as-is on subsequent resolutions.

bincraft 4.4.3

  • Registry patches (and their env / configure_args / makevars overrides) are now applied to the target package’s own source before pkgbuild::build(), not only when building patched dependencies. Previously a package that was the build target had its patch applied to a throwaway dependency repo and ignored for the uploaded binary, so source-diff/override patches silently had no effect on the shipped binary. A target patch that fails to apply now aborts the build instead of uploading an unpatched binary.

bincraft 4.4.2

  • build_patched_binary() no longer fails with is.named(envs) is not TRUE for a patch entry that sets no environment variables (e.g. a pure source-diff patch). withr::with_envvar() errors on an empty list, so it is now skipped when the entry’s env is empty.

bincraft 4.4.1

  • Source patches are now applied with git apply instead of the patch CLI, which is not present in all build environments (e.g. minimal Alpine images). git is always available, so registry source diffs apply reliably across platforms.
  • prepare_patched_repo() no longer emits a spurious normalizePath: No such file warning when staging a freshly built patched binary.
  • build_patched_binary() no longer fails with is.named(envs) is not TRUE for a patch entry that sets no env vars (e.g. a pure source-diff patch); the withr::with_envvar() wrapper is now skipped when the env is empty.

bincraft 4.3.1

bincraft 4.3.0

  • build_binary_package() gains a patches argument: a registry of per-package env / configure / Makevars overrides and source diffs that are pre-built into patched binaries and served to pak, fixing compiler- and OS-specific failures (e.g. RcppParallel) including for transitive deps.

bincraft 4.2.1

  • write_archive_rds() (and thus upload_package_index()) no longer errors when a slot has no archived versions yet — it returns an empty index instead. Fixes the Meta/archive.rds failure the first time a package lands in a fresh per-minor slot.

bincraft 4.2.0

  • process_cran_updates() gains r_minor_detection ("none"/"issue"/"classifier") and r_minor_sensitive_only, classifying each candidate via the ABI classifier and routing only risky packages to per-minor slots.
  • upload_package_index() / add_to_package_index() gain an r_minor argument to write/serve a per-minor PACKAGES* index under …/contrib/<x.y>/.