From 5caf9f9b0914906815352251f4662a7e995c40a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 14:21:08 +0000 Subject: [PATCH 01/10] docs: Replace the dead Travis and AppVeyor badges with the one that runs `NEWS.md` for 1.0.1.9002 records "Turn off Travis and AppVeyor (#10)", so both badges have reported nothing for many releases. The checks that actually run live in `.github/workflows/R-CMD-check.yaml`, whose `name:` is `rcc`, and that is the badge the rest of this fleet carries. The block also gains the `` / `` markers, which `usethis` uses to find it. Based on the pkgdown harmonization branch rather than `main` on purpose: `main` declares `output: downlit::readme_document`, and `downlit` exports no such object, so `README.md` cannot be regenerated there at all. Rendering works on this branch, so the committed output is a real render rather than a hand edit -- two consecutive renders are byte-identical. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WWhverMTZZKgEpUuTK117m --- README.Rmd | 5 +++-- README.md | 6 ++++-- index.md | 6 ++++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/README.Rmd b/README.Rmd index 9ce3198..e1e50d0 100644 --- a/README.Rmd +++ b/README.Rmd @@ -25,8 +25,9 @@ options(pager = function(files, header, title, delete.file) { # Data sets for the igraph R package -[![Linux build status](https://travis-ci.org/igraph/igraphdata.png)](https://travis-ci.org/igraph/igraphdata) -[![Windows build status](https://ci.appveyor.com/api/projects/status/6wov9hh8oprrpkhs?svg=true)](https://ci.appveyor.com/project/gaborcsardi/igraphdata) + +[![rcc](https://github.com/igraph/igraphdata/workflows/rcc/badge.svg)](https://github.com/igraph/igraphdata/actions) + This is a data R package, that contains network data sets, to be used with the igraph R package. diff --git a/README.md b/README.md index f593f2e..b5b0d91 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,10 @@ # Data sets for the igraph R package -[![Linux build status](https://travis-ci.org/igraph/igraphdata.png)](https://travis-ci.org/igraph/igraphdata) -[![Windows build status](https://ci.appveyor.com/api/projects/status/6wov9hh8oprrpkhs?svg=true)](https://ci.appveyor.com/project/gaborcsardi/igraphdata) + + +[![rcc](https://github.com/igraph/igraphdata/workflows/rcc/badge.svg)](https://github.com/igraph/igraphdata/actions) + This is a data R package, that contains network data sets, to be used with the igraph R package. diff --git a/index.md b/index.md index c35c4b6..559ef3a 100644 --- a/index.md +++ b/index.md @@ -7,8 +7,10 @@ # Data sets for the igraph R package -[![Linux build status](https://travis-ci.org/igraph/igraphdata.png)](https://travis-ci.org/igraph/igraphdata) -[![Windows build status](https://ci.appveyor.com/api/projects/status/6wov9hh8oprrpkhs?svg=true)](https://ci.appveyor.com/project/gaborcsardi/igraphdata) + + +[![rcc](https://github.com/igraph/igraphdata/workflows/rcc/badge.svg)](https://github.com/igraph/igraphdata/actions) + This is a data R package, that contains network data sets, to be used with the igraph R package. From 1ff7dd0c13a6f8fda2484d55caada9209b90974a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kirill=20M=C3=BCller?= Date: Sat, 26 Sep 2026 19:27:25 +0200 Subject: [PATCH 02/10] docs: Harmonize README and pkgdown front page rendering (#56) * docs: Adopt the shared README rendering configuration Part of harmonizing how `README.md` and the pkgdown front page are produced across the fleet. The output format becomes `cynkratemplate::readme_document`, which is a `github_document` that also writes the pkgdown front page from the same render. It carries `-smart` and `--wrap=preserve`, matching duckdb-r, which has used them longest: together they keep the pandoc pass close to an identity transform, so no smart quotes and no reflowing to 72 columns. That keeps the rendered file close to its source and makes future diffs sentence-level rather than whole-paragraph. Stating the format by name rather than repeating its settings means the settings live in one place instead of thirty. Rendering is unchanged in how it is invoked: `rmarkdown::render()`, `devtools::build_readme()` and the Knit button all work as before, and all now produce both files. The only new requirement is that cynkratemplate be installed. No prose changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WWhverMTZZKgEpUuTK117m * docs: Render README.md and index.md Output of `cynkratemplate::render_readme()`, with no hand edits. `README.md` changes are the pandoc settings from the previous commit taking effect: `--wrap=preserve` stops badge lines and sentences being broken at 72 columns, and `-smart` leaves quotes and dashes as written. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WWhverMTZZKgEpUuTK117m --------- Co-authored-by: Claude Opus 5 --- .Rbuildignore | 1 + README.Rmd | 9 +++++-- README.md | 65 ++++++++++++++++++++++++++++++--------------------- index.md | 50 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 28 deletions(-) create mode 100644 index.md diff --git a/.Rbuildignore b/.Rbuildignore index 816b707..8e43941 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -7,3 +7,4 @@ ^\.github$ ^README\.Rmd$ ^\.ccache$ +^index\.md$ diff --git a/README.Rmd b/README.Rmd index 3efd4e3..9ce3198 100644 --- a/README.Rmd +++ b/README.Rmd @@ -1,8 +1,11 @@ --- -output: downlit::readme_document +output: cynkratemplate::readme_document --- - + ```{r, setup, echo = FALSE, message = FALSE} @@ -50,6 +53,8 @@ library(igraphdata) data(package = "igraphdata") ``` +--- + # License CC BY-SA 4.0, plus see [LICENSE](LICENSE) for the licenses of the diff --git a/README.md b/README.md index cc5795b..f593f2e 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,56 @@ - + + # Data sets for the igraph R package -[![Linux build status](https://travis-ci.org/igraph/igraphdata.png)](https://travis-ci.org/igraph/igraphdata) [![Windows build status](https://ci.appveyor.com/api/projects/status/6wov9hh8oprrpkhs?svg=true)](https://ci.appveyor.com/project/gaborcsardi/igraphdata) +[![Linux build status](https://travis-ci.org/igraph/igraphdata.png)](https://travis-ci.org/igraph/igraphdata) +[![Windows build status](https://ci.appveyor.com/api/projects/status/6wov9hh8oprrpkhs?svg=true)](https://ci.appveyor.com/project/gaborcsardi/igraphdata) -This is a data R package, that contains network data sets, to be used with the igraph R package. +This is a data R package, that contains network data sets, +to be used with the igraph R package. ## Installation From CRAN: -
-install.packages("igraphdata")
+``` r +install.packages("igraphdata") +``` -You can install the development version from Github, using the [devtools package](https://github.com/hadley/devtools): +You can install the development version from Github, using the +[devtools package](https://github.com/hadley/devtools): -
-devtools::install_github("igraph/igraphdata")
+``` r +devtools::install_github("igraph/igraphdata") +``` ## Usage -
-library(igraphdata)
-data(package = "igraphdata")
-
-#> Data sets in package 'igraphdata':
-#> 
-#> Koenigsberg             Bridges of Koenigsberg from Euler's times
-#> UKfaculty               Friendship network of a UK university faculty
-#> USairports              US airport network, 2010 December
-#> enron                   Enron Email Network
-#> foodwebs                A collection of food webs
-#> immuno                  Immunoglobulin interaction network
-#> karate                  Zachary's karate club network
-#> kite                    Krackhardt's kite
-#> macaque                 Visuotactile brain areas and connections
-#> rfid                    Hospital encounter network data
-#> yeast                   Yeast protein interaction network
+``` r +library(igraphdata) +data(package = "igraphdata") +``` + + #> Data sets in package 'igraphdata': + #> + #> enron Enron Email Network + #> foodwebs A collection of food webs + #> immuno Immunoglobulin interaction network + #> karate Zachary's karate club network + #> kite Krackhardt's kite + #> Koenigsberg Bridges of Koenigsberg from Euler's times + #> macaque Visuotactile brain areas and connections + #> rfid Hospital encounter network data + #> UKfaculty Friendship network of a UK university faculty + #> USairports US airport network, 2010 December + #> yeast Yeast protein interaction network + +------------------------------------------------------------------------ # License -CC BY-SA 4.0, plus see [LICENSE](LICENSE) for the licenses of the individual data sets. +CC BY-SA 4.0, plus see [LICENSE](LICENSE) for the licenses of the +individual data sets. diff --git a/index.md b/index.md new file mode 100644 index 0000000..c35c4b6 --- /dev/null +++ b/index.md @@ -0,0 +1,50 @@ + + + + +# Data sets for the igraph R package + +[![Linux build status](https://travis-ci.org/igraph/igraphdata.png)](https://travis-ci.org/igraph/igraphdata) +[![Windows build status](https://ci.appveyor.com/api/projects/status/6wov9hh8oprrpkhs?svg=true)](https://ci.appveyor.com/project/gaborcsardi/igraphdata) + +This is a data R package, that contains network data sets, +to be used with the igraph R package. + +## Installation + +From CRAN: + +``` r +install.packages("igraphdata") +``` + +You can install the development version from Github, using the +[devtools package](https://github.com/hadley/devtools): + +``` r +devtools::install_github("igraph/igraphdata") +``` + +## Usage + +``` r +library(igraphdata) +data(package = "igraphdata") +``` + + #> Data sets in package 'igraphdata': + #> + #> enron Enron Email Network + #> foodwebs A collection of food webs + #> immuno Immunoglobulin interaction network + #> karate Zachary's karate club network + #> kite Krackhardt's kite + #> Koenigsberg Bridges of Koenigsberg from Euler's times + #> macaque Visuotactile brain areas and connections + #> rfid Hospital encounter network data + #> UKfaculty Friendship network of a UK university faculty + #> USairports US airport network, 2010 December + #> yeast Yeast protein interaction network From 5baadf4637c89af5ac3de56cc09c5a4a454ab7b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kirill=20M=C3=BCller?= Date: Sat, 26 Sep 2026 19:27:26 +0200 Subject: [PATCH 03/10] docs: Break lines at meaning boundaries (#57) * docs: Break lines at meaning boundaries Reformatting only, no wording changes: prose in README.Rmd and the roxygen comments under R/ now breaks at sentence and clause boundaries rather than wrapping to a fixed width. The payoff is sentence-level diffs. A reworded sentence touches one line instead of reflowing the paragraph around it, so review sees the change and not the rewrap. man/*.Rd is regenerated because roxygen2 passes source line breaks through to the .Rd. The rendered help is byte-identical -- checked with tools::Rd2txt() over every topic -- as is the rendered README. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WWhverMTZZKgEpUuTK117m * docs: Start every broken sentence on its own line A sentence that spans more than one line, and the sentence after it, must each begin at the start of a line. The maintainer note at the top of README.Rmd was still wrapped to a fixed width, so two sentences started mid-line; README.md and index.md are re-rendered from it. The remaining detector hits are bibliographic entries in @references and in the "Reference:" blocks of the foodwebs descriptions, where the line breaks separate authors, title and venue rather than sentences. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WWhverMTZZKgEpUuTK117m * docs: Indent continuation lines to keep sentence spacing Continuation lines of a roxygen paragraph now carry two spaces after `#'` instead of one. The first line of a paragraph, and every tag line, keeps its single space. roxygen2 strips `#'` plus exactly one space, so the extra space reaches the `.Rd`, and `Rd2txt()` renders a line break followed by indentation as two spaces rather than one. That is what lets a sentence pair keep its gap across a line break. This package sets `Roxygen: list(markdown = TRUE)`, and commonmark strips the indent again, so here the change is a source convention only. The rendered help is byte-identical, checked with `tools::Rd2txt()` over every topic, and `man/` is unchanged. Most of the dataset documentation already indents its continuation lines by three spaces, so only four lines needed the second space. The `\describe{}` blocks that carry the per-dataset provenance keep the indentation their structure needs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WWhverMTZZKgEpUuTK117m * docs: Drop the continuation-line indent The two-space continuation indent never reached the `.Rd` files. This package sets `markdown = TRUE` in its `Roxygen` field, and commonmark strips the leading whitespace of a continuation line before roxygen2 writes the topic, so the indent only ever lived in the source. It bought nothing there, so it comes out. man/ is left to CI, which regenerates it from these sources. A line break which follows the end of a sentence currently renders as one space rather than two. Restoring that gap needs a patched roxygen2, which is a separate decision and a separate pull request, so it is deliberately not part of this change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WWhverMTZZKgEpUuTK117m * chore: Auto-update from GitHub Actions Run: https://github.com/igraph/igraphdata/actions/runs/34784578558 --- R/Koenigsberg.R | 30 +++++------- R/UKfaculty.R | 22 ++++----- R/USairports.R | 42 +++++++---------- R/enron.R | 76 ++++++++++++------------------ R/foodweb.R | 104 ++++++++++++++++------------------------- R/igraphdata-package.R | 6 +-- R/immuno.R | 13 +++--- R/karate.R | 62 +++++++++++------------- R/kite.R | 15 +++--- R/macaque.R | 20 ++++---- R/netzschleuder.R | 7 +-- R/rfid.R | 34 ++++++-------- R/yeast.R | 66 +++++++++++--------------- README.Rmd | 12 ++--- README.md | 12 ++--- index.md | 9 ++-- man/Koenigsberg.Rd | 30 +++++------- man/UKfaculty.Rd | 22 ++++----- man/USairports.Rd | 42 +++++++---------- man/enron.Rd | 76 ++++++++++++------------------ man/foodwebs.Rd | 104 ++++++++++++++++------------------------- man/immuno.Rd | 13 +++--- man/karate.Rd | 62 +++++++++++------------- man/kite.Rd | 15 +++--- man/macaque.Rd | 18 +++---- man/netzschleuder.Rd | 7 +-- man/rfid.Rd | 34 ++++++-------- man/yeast.Rd | 66 +++++++++++--------------- 28 files changed, 415 insertions(+), 604 deletions(-) diff --git a/R/Koenigsberg.R b/R/Koenigsberg.R index 8dde8c4..09ac3a9 100644 --- a/R/Koenigsberg.R +++ b/R/Koenigsberg.R @@ -2,19 +2,16 @@ #' #' @description #' -#' The Seven Bridges of Koenigsberg is a notable historical problem in -#' mathematics. Its negative resolution by Leonhard Euler in 1735 laid -#' the foundations of graph theory and presaged the idea of topology. +#' The Seven Bridges of Koenigsberg is a notable historical problem in mathematics. +#' Its negative resolution by Leonhard Euler in 1735 laid the foundations of graph theory and presaged the idea of topology. #' -#' The city of Koenigsberg in Prussia (now Kaliningrad, Russia) was set on -#' both sides of the Pregel River, and included two large islands which -#' were connected to each other and the mainland by seven bridges +#' The city of Koenigsberg in Prussia (now Kaliningrad, Russia) was set on both sides of the Pregel River, +#' and included two large islands which were connected to each other and the mainland by seven bridges #' -#' The problem was to find a walk through the city that would cross each -#' bridge once and only once. The islands could not be reached by any route -#' other than the bridges, and every bridge must have been crossed -#' completely every time (one could not walk half way onto the bridge and -#' then turn around and later cross the other half from the other side). +#' The problem was to find a walk through the city that would cross each bridge once and only once. +#' The islands could not be reached by any route other than the bridges, +#' and every bridge must have been crossed completely every time +#' (one could not walk half way onto the bridge and then turn around and later cross the other half from the other side). #' #' Euler proved that the problem has no solution. #' @@ -25,16 +22,13 @@ #' @usage #' Koenigsberg #' @format -#' An undirected `igraph` graph object with vertex attributes -#' \sQuote{name} and \sQuote{Euler_letter}, the latter is the notation -#' from Eulers original paper; and edge attributes \sQuote{name} (the name -#' of the bridge) and \sQuote{Euler_letter}, again, Euler's notation -#' from his paper. +#' An undirected `igraph` graph object with vertex attributes \sQuote{name} and \sQuote{Euler_letter}, +#' the latter is the notation from Eulers original paper; +#' and edge attributes \sQuote{name} (the name of the bridge) and \sQuote{Euler_letter}, again, Euler's notation from his paper. #' #' This dataset is in the public domain. #' @references Leonhard Euler, “Solutio problematis ad geometriam situs pertinensis” #' Commentarii Academiae Scientarum Imperialis Petropolitanae, 8 (1736), 128–140 + Plate VIII. -#' @source Wikipedia, -#' +#' @source Wikipedia, #' @keywords datasets NULL diff --git a/R/UKfaculty.R b/R/UKfaculty.R index f5994d8..9b376b2 100644 --- a/R/UKfaculty.R +++ b/R/UKfaculty.R @@ -1,28 +1,24 @@ #' Friendship network of a UK university faculty #' #' @description -#' The personal friendship network of a faculty of a UK -#' university, consisting of 81 vertices (individuals) and 817 directed -#' and weighted connections. The school affiliation of each individual is -#' stored as a vertex attribute. This dataset can serve as a testbed for -#' community detection algorithms. +#' The personal friendship network of a faculty of a UK university, +#' consisting of 81 vertices (individuals) and 817 directed and weighted connections. +#' The school affiliation of each individual is stored as a vertex attribute. +#' This dataset can serve as a testbed for community detection algorithms. #' #' @name UKfaculty #' @docType data #' @usage #' UKfaculty #' @format -#' A directed `igraph` graph object with vertex attribute -#' \sQuote{Group}, the numeric id of the school affiliation, and edge -#' attribute \sQuote{weight}, i.e. the graph is weighted. +#' A directed `igraph` graph object with vertex attribute \sQuote{Group}, the numeric id of the school affiliation, +#' and edge attribute \sQuote{weight}, i.e. the graph is weighted. #' -#' This dataset is licensed under a Creative Commons -#' Attribution-Share Alike 2.0 UK: England & Wales License, +#' This dataset is licensed under a Creative Commons Attribution-Share Alike 2.0 UK: England & Wales License, #' see for details. #' Please cite the reference below if you use this dataset. -#' @references Nepusz T., Petroczi A., Negyessy L., Bazso F.: Fuzzy -#' communities and the concept of bridgeness in complex -#' networks. Physical Review E 77:016107, 2008. +#' @references Nepusz T., Petroczi A., Negyessy L., Bazso F.: Fuzzy communities and the concept of bridgeness in complex networks. +#' Physical Review E 77:016107, 2008. #' \doi{10.1103/PhysRevE.77.016107} #' @source See reference below. #' @keywords datasets diff --git a/R/USairports.R b/R/USairports.R index 75edf30..2a41fdf 100644 --- a/R/USairports.R +++ b/R/USairports.R @@ -2,12 +2,11 @@ #' #' @description #' -#' The network of passanger flights between airports in the United -#' States. The data set was compiled based on flights in 2010 -#' December. This network is directed and edge directions correspond to -#' flight directions. Each edge is specific to a single carrier aircraft -#' type. Multiple carriers between the same two airports are denoted by -#' multiple edges. +#' The network of passanger flights between airports in the United States. +#' The data set was compiled based on flights in 2010 December. +#' This network is directed and edge directions correspond to flight directions. +#' Each edge is specific to a single carrier aircraft type. +#' Multiple carriers between the same two airports are denoted by multiple edges. #' #' See information about the included meta-data below. #' @@ -18,35 +17,28 @@ #' @usage #' USairports #' @format -#' A directed `igraph` graph object, with multiple edges. It has a -#' \sQuote{name} graph attribute, and several vertex and edge -#' attributes. The vertex attributes: +#' A directed `igraph` graph object, with multiple edges. +#' It has a \sQuote{name} graph attribute, and several vertex and edge attributes. +#' The vertex attributes: #' \describe{ -#' \item{name}{Symbolic vertex name, this is the three letter IATA -#' airport code.} +#' \item{name}{Symbolic vertex name, this is the three letter IATA airport code.} #' \item{City}{City and state, where the airport is located.} #' \item{Position}{Position of the airport, in WGS coordinates.} #' } #' #' Edge attributes: #' \describe{ -#' \item{Carrier}{Name of the airline. The network includes both -#' domestic and international carriers that performed at least one -#' flight in December of 2010.} -#' \item{Departures}{The number of departures (for a given airline and -#' aircraft type.} -#' \item{Seats}{The total number of seats available on the flights -#' carried out by a given airline, using a given aircraft type.} -#' \item{Passengers}{The total number of passangers on the flights -#' carried out by a given airline, using a given aircraft type.} +#' \item{Carrier}{Name of the airline. +#' The network includes both domestic and international carriers that performed at least one flight in December of 2010.} +#' \item{Departures}{The number of departures (for a given airline and aircraft type.} +#' \item{Seats}{The total number of seats available on the flights carried out by a given airline, using a given aircraft type.} +#' \item{Passengers}{The total number of passangers on the flights carried out by a given airline, using a given aircraft type.} #' \item{Aircraft}{Type of the aircraft.} #' \item{Distance}{The distance between the two airports, in miles.} #' } #' @source -#' Most of this information was downloaded from The Research and -#' Innovative Technology Administration (RITA). See -#' for details. The airport -#' position information was collected from Wikipedia and other public -#' online sources. +#' Most of this information was downloaded from The Research and Innovative Technology Administration (RITA). +#' See for details. +#' The airport position information was collected from Wikipedia and other public online sources. #' @keywords datasets NULL diff --git a/R/enron.R b/R/enron.R index b3728de..4ee1407 100644 --- a/R/enron.R +++ b/R/enron.R @@ -14,11 +14,9 @@ #' A directed `igraph` graph object. #' #' Graph attributes: \itemize{ -#' \item \sQuote{LDC_names} The names of the 32 LDC catagories the emails -#' are classfied into by Michael W. Berry +#' \item \sQuote{LDC_names} The names of the 32 LDC catagories the emails are classfied into by Michael W. Berry #' () -#' \item \sQuote{LDC_desc} Longer descriptions of the 32 LDC -#' categories. +#' \item \sQuote{LDC_desc} Longer descriptions of the 32 LDC categories. #' \item \sQuote{Citation} Additionally, see also the references below. #' \item \sQuote{name} #' } @@ -30,60 +28,48 @@ #' } #' #' Edge attributes: \itemize{ -#' \item \sQuote{Time} When the email was sent. Note that some time -#' labels are from 1979, these are certainly wrong and you might want -#' to remove them before analyses that include time. -#' \item \sQuote{Reciptype} Recipient type, \sQuote{to}, \sQuote{cc} or -#' \sQuote{bcc}. -#' \item \sQuote{Topic} Assigned based on 3-means clustering of -#' randomly selected 3,120 out of all 125,409 messages, then NN -#' classification for the whole corpus. Note that topic 0 means an -#' outlier, e.g., too few words or all meaningless numbers in the -#' message body. -#' \item \sQuote{LDC_topic} Assigned based on Michael W. Berry's 2001 -#' \dQuote{Annotated (by Topic) Enron Email Data Set.} +#' \item \sQuote{Time} When the email was sent. +#' Note that some time labels are from 1979, +#' these are certainly wrong and you might want to remove them before analyses that include time. +#' \item \sQuote{Reciptype} Recipient type, \sQuote{to}, \sQuote{cc} or \sQuote{bcc}. +#' \item \sQuote{Topic} Assigned based on 3-means clustering of randomly selected 3,120 out of all 125,409 messages, +#' then NN classification for the whole corpus. +#' Note that topic 0 means an outlier, e.g., too few words or all meaningless numbers in the message body. +#' \item \sQuote{LDC_topic} Assigned based on Michael W. Berry's 2001 \dQuote{Annotated (by Topic) Enron Email Data Set.} #' () -#' There are 32 topics. Topic "0" means an outlier, e.g., too few words -#' or all meaningless numbers in the message body, etc. Topic "-1" -#' means there is no matching topic. +#' There are 32 topics. +#' Topic "0" means an outlier, e.g., too few words or all meaningless numbers in the message body, etc. +#' Topic "-1" means there is no matching topic. #' } #' @references #' C.E. Priebe, J.M. Conroy, D.J. Marchette, and Y. Park, -#' Scan Statistics on Enron Graphs Computational and Mathematical -#' Organization Theory, Volume 11, Number 3, p229 - 247, October 2005, -#' Springer Science+Business Media B.V. \doi{10.1007/s10588-005-5378-z} +#' Scan Statistics on Enron Graphs Computational and Mathematical Organization Theory, +#' Volume 11, Number 3, p229 - 247, October 2005, Springer Science+Business Media B.V. \doi{10.1007/s10588-005-5378-z} #' #' C.E. Priebe, J.M. Conroy, D.J. Marchette, and Y. Park, -#' Scan Statistics on Enron Graphs, SIAM International Conference on -#' Data Mining, Workshop on Link Analysis, Counterterrorism and Security, -#' Newport Beach, California, April 23, 2005. +#' Scan Statistics on Enron Graphs, SIAM International Conference on Data Mining, +#' Workshop on Link Analysis, Counterterrorism and Security, Newport Beach, California, April 23, 2005. #' -#' Gina Kolata, Enron Offers an Unlikely Boost to E-Mail Surveillance, -#' New York Times, Week in Review, May 22, 2005. +#' Gina Kolata, Enron Offers an Unlikely Boost to E-Mail Surveillance, New York Times, Week in Review, May 22, 2005. #' -#' C.E. Priebe, Scan Statistics on Enron Graphs, IPAM Summer Graduate -#' School: Intelligent Extraction of Information from Graphs and High -#' Dimensional Data, UCLA, July 11-29, 2005. +#' C.E. Priebe, Scan Statistics on Enron Graphs, +#' IPAM Summer Graduate School: Intelligent Extraction of Information from Graphs and High Dimensional Data, +#' UCLA, July 11-29, 2005. #' -#' C.E. Priebe, Scan Statistics on Enron Graphs, 2005 Fall Department -#' of Applied Mathematics and Statistics Seminars, September 15, 2005, -#' The Johns Hopkins University. +#' C.E. Priebe, Scan Statistics on Enron Graphs, +#' 2005 Fall Department of Applied Mathematics and Statistics Seminars, September 15, 2005, The Johns Hopkins University. #' -#' Y. Park, C.E. Priebe, D.J. Marchette, Scan Statistics on Enron -#' Hypergraphs, Interface 2008, Durham, North Carolina, May 21, 2008, +#' Y. Park, C.E. Priebe, D.J. Marchette, Scan Statistics on Enron Hypergraphs, Interface 2008, Durham, North Carolina, May 21, 2008, #' -#' Y. Park, C.E. Priebe, D.J. Marchette, Anomaly Detection using Scan -#' Statistics on Enron Graphs and Hypergraphs, The Satellite Workshop of -#' the IASC 2008 Conference, Seoul, Korea, December 1-3, 2008. +#' Y. Park, C.E. Priebe, D.J. Marchette, Anomaly Detection using Scan Statistics on Enron Graphs and Hypergraphs, +#' The Satellite Workshop of the IASC 2008 Conference, Seoul, Korea, December 1-3, 2008. #' -#' Y. Park, C.E. Priebe, D.J. Marchette, A. Youssef, Anomaly Detection -#' using Scan Statistics on Time Series of Hypergraphs, Workshop on Link -#' Analysis, Counterterrorism and Security at the SIAM International -#' Conference on Data Mining, Sparks, Nevada, May 1-3, 2009, +#' Y. Park, C.E. Priebe, D.J. Marchette, A. Youssef, Anomaly Detection using Scan Statistics on Time Series of Hypergraphs, +#' Workshop on Link Analysis, Counterterrorism and Security at the SIAM International Conference on Data Mining, +#' Sparks, Nevada, May 1-3, 2009, #' -#' Y. Park, C.E. Priebe, A. Youssef, Anomaly Detection in Time Series of -#' Graphs using Fusion of Invariants, Computational and Mathematical -#' Organization Theory, submitted, 2010. \doi{10.1109/JSTSP.2012.2233712} +#' Y. Park, C.E. Priebe, A. Youssef, Anomaly Detection in Time Series of Graphs using Fusion of Invariants, +#' Computational and Mathematical Organization Theory, submitted, 2010. \doi{10.1109/JSTSP.2012.2233712} #' @source #' NULL diff --git a/R/foodweb.R b/R/foodweb.R index 8cada05..13ac20b 100644 --- a/R/foodweb.R +++ b/R/foodweb.R @@ -1,8 +1,8 @@ #' A collection of food webs #' #' @description -#' A list of graphs. Each one is a food web, i.e. a directed -#' graph of predator-prey relationships. +#' A list of graphs. +#' Each one is a food web, i.e. a directed graph of predator-prey relationships. #' #' #' @@ -11,13 +11,12 @@ #' @usage #' foodwebs #' @format -#' A named list of directed `igraph` graph objects. Here are the -#' list of the graphs included: +#' A named list of directed `igraph` graph objects. +#' Here are the list of the graphs included: #' \describe{ #' \item{\sQuote{ChesLower}}{Lower Chesapeake Bay in Summer. #' -#' Reference: Hagy, J.D. (2002) Eutrophication, hypoxia and -#' trophic transfer efficiency in Chesapeake Bay PhD Dissertation, +#' Reference: Hagy, J.D. (2002) Eutrophication, hypoxia and trophic transfer efficiency in Chesapeake Bay PhD Dissertation, #' University of Maryland at College Park (USA), 446 pp.} #' \item{\sQuote{ChesMiddle}}{Middle Chesapeake Bay in Summer. #' @@ -27,95 +26,77 @@ #' Reference: same as for \sQuote{ChesLower}.} #' \item{\sQuote{Chesapeake}}{Chesapeake Bay Mesohaline Network. #' -#' Reference: Baird D. & Ulanowicz R.E. (1989) The seasonal dynamics -#' of the Chesapeake Bay ecosystem. Ecological Monographs 59:329-364.} +#' Reference: Baird D. & Ulanowicz R.E. (1989) The seasonal dynamics of the Chesapeake Bay ecosystem. +#' Ecological Monographs 59:329-364.} #' \item{\sQuote{CrystalC}}{Crystal River Creek (Control). #' -#' Reference: Homer, M. and W.M. Kemp. Unpublished Ms. See also -#' Ulanowicz, R.E. 1986. Growth and Development: Ecosystems -#' Phenomenology. Springer, New York. pp 69-79.} +#' Reference: Homer, M. and W.M. Kemp. Unpublished Ms. +#' See also Ulanowicz, R.E. 1986. Growth and Development: Ecosystems Phenomenology. Springer, New York. pp 69-79.} #' \item{\sQuote{CrystalD}}{Crystal River Creek (Delta Temp). #' #' Reference: same as for \sQuote{CrystalD}.} #' \item{\sQuote{Maspalomas}}{Charca de Maspalomas. #' -#' Reference: Almunia, J., G. Basterretxea, J. Aristegui, and -#' R.E. Ulanowicz. (1999) Benthic- Pelagic switching in a coastal -#' subtropical lagoon. Estuarine, Coastal and Shelf Science -#' 49:363-384.} +#' Reference: Almunia, J., G. Basterretxea, J. Aristegui, and R.E. Ulanowicz. (1999) +#' Benthic- Pelagic switching in a coastal subtropical lagoon. Estuarine, Coastal and Shelf Science 49:363-384.} #' \item{\sQuote{Michigan}}{Lake Michigan Control network. #' -#' Reference: Krause, A. and D. Mason. (In preparation.) A. Krause, -#' PhD. Dissertation, Michigan State University. Ann Arbor, MI. USA.} +#' Reference: Krause, A. and D. Mason. (In preparation.) +#' A. Krause, PhD. Dissertation, Michigan State University. Ann Arbor, MI. USA.} #' \item{\sQuote{Mondego}}{Mondego Estuary - Zostrea site. #' -#' Reference: Patricio, J. (In Preparation) Master's -#' Thesis. University of Coimbra, Coimbra, Portugal.} +#' Reference: Patricio, J. (In Preparation) Master's Thesis. University of Coimbra, Coimbra, Portugal.} #' \item{\sQuote{Narragan}}{Narragansett Bay Model. #' -#' Reference: Monaco, M.E. and R.E. Ulanowicz. (1997) Comparative -#' ecosystem trophic structure of three U.S. Mid-Atlantic -#' estuaries. Mar. Ecol. Prog. Ser. 161:239-254.} +#' Reference: Monaco, M.E. and R.E. Ulanowicz. (1997) Comparative ecosystem trophic structure of three U.S. Mid-Atlantic estuaries. +#' Mar. Ecol. Prog. Ser. 161:239-254.} #' \item{\sQuote{Rhode}}{Rhode River Watershed - Water Budget. #' -#' Reference: Correll, D. (Unpublished manuscript) Smithsonian -#' Institute, Chesapeake Bay Center for Environmental Research, +#' Reference: Correll, D. (Unpublished manuscript) Smithsonian Institute, Chesapeake Bay Center for Environmental Research, #' Edgewater, Maryland 21037-0028 USA.} #' \item{\sQuote{StMarks}}{St. Marks River (Florida) Flow network. #' #' Reference: Baird, D., J. Luczkovich and R. R. Christian. (1998) -#' Assessment of spatial and temporal variability in ecosystem -#' attributes of the St Marks National Wildlife Refuge, Apalachee Bay, -#' Florida. Estuarine, Coastal, and Shelf Science 47: 329-349.} +#' Assessment of spatial and temporal variability in ecosystem attributes of the St Marks National Wildlife Refuge, +#' Apalachee Bay, Florida. Estuarine, Coastal, and Shelf Science 47: 329-349.} #' \item{\sQuote{baydry}}{Florida Bay Trophic Exchange Matrix, dry season. #' -#' Reference: Ulanowicz, R. E., C. Bondavalli, and -#' M. S. Egnotovich. 1998. Network analysis of trophic dynamics in -#' South Florida ecosystems, FY 97: the Florida Bay ecosystem. Annual -#' Report to the United States Geological Service Biological Resources -#' Division, University of Miami Coral Gables, \[UM-CES\] CBL 98-123, -#' Maryland System Center for Environmental Science, Chesapeake -#' Biological Laboratory, Maryland, USA.} +#' Reference: Ulanowicz, R. E., C. Bondavalli, and M. S. Egnotovich. 1998. +#' Network analysis of trophic dynamics in South Florida ecosystems, FY 97: the Florida Bay ecosystem. +#' Annual Report to the United States Geological Service Biological Resources Division, University of Miami Coral Gables, +#' \[UM-CES\] CBL 98-123, Maryland System Center for Environmental Science, Chesapeake Biological Laboratory, Maryland, USA.} #' \item{\sQuote{baywet}}{Florida Bay Trophic Exchange Matrix, wet season. #' #' Reference: same as for \sQuote{baydry}.} #' \item{\sQuote{cypdry}}{Cypress, dry season. #' -#' Reference: Ulanowicz, R. E., C. Bondavalli, and -#' M. S. Egnotovich. 1997. Network analysis of trophic dynamics in -#' South Florida ecosystems, FY 96: the cypress wetland -#' ecosystem. Annual Report to the United States Geological Service -#' Biological Resources Division, University of Miami Coral Gables, -#' \[UM-CES\] CBL 97-075, Maryland System Center for Environmental -#' Science, Chesapeake Biological Laboratory.} +#' Reference: Ulanowicz, R. E., C. Bondavalli, and M. S. Egnotovich. 1997. +#' Network analysis of trophic dynamics in South Florida ecosystems, FY 96: the cypress wetland ecosystem. +#' Annual Report to the United States Geological Service Biological Resources Division, University of Miami Coral Gables, +#' \[UM-CES\] CBL 97-075, Maryland System Center for Environmental Science, Chesapeake Biological Laboratory.} #' \item{\sQuote{cypwet}}{Cypress, wet season. #' #' Reference: same as for \sQuote{cypdry}.} #' \item{\sQuote{gramdry}}{Everglades Graminoids - Dry Season. #' -#' Reference: Ulanowicz, R. E., J. J. Heymans, and -#' M. S. Egnotovich. 2000. Network analysis of trophic dynamics in -#' South Florida ecosystems, FY 99: the graminoid ecosystem. Technical -#' Report TS-191-99, Maryland System Center for Environmental Science, -#' Chesapeake Biological Laboratory, Maryland, USA.} +#' Reference: Ulanowicz, R. E., J. J. Heymans, and M. S. Egnotovich. 2000. +#' Network analysis of trophic dynamics in South Florida ecosystems, FY 99: the graminoid ecosystem. +#' Technical Report TS-191-99, Maryland System Center for Environmental Science, Chesapeake Biological Laboratory, Maryland, USA.} #' \item{\sQuote{gramwet}}{Everglades Graminoids - Wet Season. #' #' Reference: same as for \sQuote{gramdry}.} #' \item{\sQuote{mangdry}}{Mangrove Estuary, Dry Season. #' -#' Reference: Ulanowicz, R. E., C. Bondavalli, J. J. Heymans, and -#' M. S. Egnotovich. 1999. Network analysis of trophic dynamics in -#' South Florida ecosystems, FY 98: the mangrove ecosystem. Technical -#' Report TS-191-99, Maryland System Center for Environmental Science, -#' Chesapeake Biological Laboratory, Maryland, USA.} +#' Reference: Ulanowicz, R. E., C. Bondavalli, J. J. Heymans, and M. S. Egnotovich. 1999. +#' Network analysis of trophic dynamics in South Florida ecosystems, FY 98: the mangrove ecosystem. +#' Technical Report TS-191-99, Maryland System Center for Environmental Science, Chesapeake Biological Laboratory, Maryland, USA.} #' \item{\sQuote{mangwet}}{Mangrove Estuary, Wet Season. #' #' Reference: same as for \sQuote{mangdry}.} #' } #' -#' Each graph has the following vertex attributes: \sQuote{name} is the -#' name of the species, \sQuote{ECO} is the type of the node, and -#' integer value between one and five, meaning: +#' Each graph has the following vertex attributes: \sQuote{name} is the name of the species, +#' \sQuote{ECO} is the type of the node, and integer value between one and five, meaning: #' \enumerate{ #' \item Living/producing compartment #' \item Other compartment @@ -123,18 +104,13 @@ #' \item Output #' \item Respiration. #' } -#' The \sQuote{Biomass} vertex attribute contains the biomass of the -#' species. +#' The \sQuote{Biomass} vertex attribute contains the biomass of the species. #' -#' Edges are weighted, and the weights denote energy flux between the -#' species involved. +#' Edges are weighted, and the weights denote energy flux between the species involved. #' -#' The graphs also contain some informative graph attributes: -#' \sQuote{Author}, \sQuote{Citation}, \sQuote{URL}, and -#' \sQuote{name}. +#' The graphs also contain some informative graph attributes: \sQuote{Author}, \sQuote{Citation}, \sQuote{URL}, and \sQuote{name}. #' @references See them above. -#' @source See references for the individual webs above. The data itself -#' was downloaded from -#' . +#' @source See references for the individual webs above. +#' The data itself was downloaded from . #' @keywords datasets NULL diff --git a/R/igraphdata-package.R b/R/igraphdata-package.R index 6d4462c..1586536 100644 --- a/R/igraphdata-package.R +++ b/R/igraphdata-package.R @@ -2,11 +2,9 @@ "_PACKAGE" #' @section How to use the data sets -#' After loading the \pkg{igraphdata} package, the various data sets are available -#' by accessing them directly. +#' After loading the \pkg{igraphdata} package, the various data sets are available by accessing them directly. #' -#' Get a list of data sets included in this package with -#' `data(package = "igraphdata")`. +#' Get a list of data sets included in this package with `data(package = "igraphdata")`. ## usethis namespace: start diff --git a/R/immuno.R b/R/immuno.R index d485c0d..5eb6eea 100644 --- a/R/immuno.R +++ b/R/immuno.R @@ -2,11 +2,10 @@ #' #' @description #' -#' The undirected and connected network of interactions -#' in the immunoglobulin protein. It is made up of 1316 vertices -#' representing amino-acids and an edge is drawn between two -#' amino-acids if the shortest distance between their C_alpha atoms -#' is smaller than the threshold value \eqn{\theta=8}{theta=8} Angstrom. +#' The undirected and connected network of interactions in the immunoglobulin protein. +#' It is made up of 1316 vertices representing amino-acids +#' and an edge is drawn between two amino-acids +#' if the shortest distance between their C_alpha atoms is smaller than the threshold value \eqn{\theta=8}{theta=8} Angstrom. #' #' #' @@ -19,8 +18,8 @@ #' #' Graph attributes: \sQuote{name}, \sQuote{Citation}, \sQuote{Author}. #' @references -#' D. Gfeller, Simplifying complex networks: from a clustering to a -#' coarse graining strategy, *PhD Thesis EPFL*, no 3888, 2007. +#' D. Gfeller, Simplifying complex networks: from a clustering to a coarse graining strategy, +#' *PhD Thesis EPFL*, no 3888, 2007. #' #' @source #' See reference below. diff --git a/R/karate.R b/R/karate.R index 8395582..a4616c6 100644 --- a/R/karate.R +++ b/R/karate.R @@ -2,40 +2,33 @@ #' #' @description #' -#' Social network between members of a university karate club, led by -#' president John A. and karate instructor Mr. Hi (pseudonyms). +#' Social network between members of a university karate club, led by president John A. and karate instructor Mr. Hi (pseudonyms). #' -#' The edge weights are the number of common activities the club -#' members took part of. These activities were: +#' The edge weights are the number of common activities the club members took part of. +#' These activities were: #' \enumerate{ #' \item Association in and between academic classes at the university. -#' \item Membership in Mr. Hi's private karate studio on the east side -#' of the city where Mr. Hi taught nights as a part-time instructor. -#' \item Membership in Mr. Hi's private karate studio on the east side -#' of the city, where many of his supporters worked out on weekends. -#' \item Student teaching at the east-side karate studio referred to in -#' (2). This is different from (2) in that student teachers interacted -#' with each other, but were prohibited from interacting with their -#' students. -#' \item Interaction at the university rathskeller, located in the same -#' basement as the karate club's workout area. -#' \item Interaction at a student-oriented bar located across the -#' street from the university campus. -#' \item Attendance at open karate tournaments held through the area at -#' private karate studios. -#' \item Attendance at intercollegiate karate tournaments held at local -#' universities. Since both open and intercollegiate tournaments were -#' held on Saturdays, attendance at both was impossible. +#' \item Membership in Mr. Hi's private karate studio on the east side of the city +#' where Mr. Hi taught nights as a part-time instructor. +#' \item Membership in Mr. Hi's private karate studio on the east side of the city, +#' where many of his supporters worked out on weekends. +#' \item Student teaching at the east-side karate studio referred to in (2). +#' This is different from (2) in that student teachers interacted with each other, +#' but were prohibited from interacting with their students. +#' \item Interaction at the university rathskeller, located in the same basement as the karate club's workout area. +#' \item Interaction at a student-oriented bar located across the street from the university campus. +#' \item Attendance at open karate tournaments held through the area at private karate studios. +#' \item Attendance at intercollegiate karate tournaments held at local universities. +#' Since both open and intercollegiate tournaments were held on Saturdays, attendance at both was impossible. #' } #' -#' Zachary studied conflict and fission in this network, as the karate -#' club was split into two separate clubs, after long disputes between -#' two factions of the club, one led by John A., the other by Mr. Hi. +#' Zachary studied conflict and fission in this network, +#' as the karate club was split into two separate clubs, after long disputes between two factions of the club, +#' one led by John A., the other by Mr. Hi. #' -#' The \sQuote{Faction} vertex attribute gives the faction memberships of -#' the actors. After the split of the club, club members chose their -#' new clubs based on their factions, except actor no. 9, who was in John -#' A.'s faction but chose Mr. Hi's club. +#' The \sQuote{Faction} vertex attribute gives the faction memberships of the actors. +#' After the split of the club, club members chose their new clubs based on their factions, +#' except actor no. 9, who was in John A.'s faction but chose Mr. Hi's club. #' #' #' @@ -44,19 +37,18 @@ #' @usage #' karate #' @format -#' An undirected `igraph` graph object. Vertex no. 1 is Mr. Hi, -#' vertex no. 34 corresponds to John A. +#' An undirected `igraph` graph object. +#' Vertex no. 1 is Mr. Hi, vertex no. 34 corresponds to John A. #' #' Graph attributes: \sQuote{name}, \sQuote{Citation}, \sQuote{Author}. #' -#' Vertex attributes: \sQuote{name}, \sQuote{Faction}, \sQuote{color} is -#' the same as \sQuote{Faction}, \sQuote{label} are short labels for plotting. +#' Vertex attributes: \sQuote{name}, \sQuote{Faction}, \sQuote{color} is the same as \sQuote{Faction}, +#' \sQuote{label} are short labels for plotting. #' #' Edge attribute: \sQuote{weight}. #' @references -#' Wayne W. Zachary. An Information Flow Model for Conflict and Fission -#' in Small Groups. *Journal of Anthropological Research* Vol. 33, -#' No. 4 452-473 \doi{10.1086/jar.33.4.3629752} +#' Wayne W. Zachary. An Information Flow Model for Conflict and Fission in Small Groups. +#' *Journal of Anthropological Research* Vol. 33, No. 4 452-473 \doi{10.1086/jar.33.4.3629752} #' @source #' See reference below. #' @keywords datasets diff --git a/R/kite.R b/R/kite.R index 8b8afc7..e035576 100644 --- a/R/kite.R +++ b/R/kite.R @@ -3,22 +3,19 @@ #' @description #' #' Krackhardt's kite is a fictional social network with ten actors. -#' It is a small (though not the smallest possible) graph for which the most -#' central actors are different according to the three classic centrality -#' measures: degree, closeness and betweenness. +#' It is a small (though not the smallest possible) graph for which the most central actors are different +#' according to the three classic centrality measures: degree, closeness and betweenness. #' #' @name kite #' @docType data #' @usage #' kite #' @format -#' An undirected igraph graph with graph attributes `name`, -#' `layout`, `Citation`, `Author`, `URL`, and vertex -#' attributes `label`, `name` and `Firstname`. +#' An undirected igraph graph with graph attributes `name`, `layout`, `Citation`, `Author`, `URL`, +#' and vertex attributes `label`, `name` and `Firstname`. #' @references -#' Assessing the Political Landscape: Structure, Cognition, and Power in -#' Organizations. David Krackhardt. *Admin. Sci. Quart.* 35, 342-369, -#' 1990. \doi{10.2307/2393394} +#' Assessing the Political Landscape: Structure, Cognition, and Power in Organizations. +#' David Krackhardt. *Admin. Sci. Quart.* 35, 342-369, 1990. \doi{10.2307/2393394} #' @source #' #' @keywords datasets diff --git a/R/macaque.R b/R/macaque.R index c56b1e7..b5f05aa 100644 --- a/R/macaque.R +++ b/R/macaque.R @@ -2,9 +2,8 @@ #' #' @description #' -#' Graph model of the visuotactile brain areas and connections of the -#' macaque monkey. The model consists of 45 areas and 463 directed -#' connections. +#' Graph model of the visuotactile brain areas and connections of the macaque monkey. +#' The model consists of 45 areas and 463 directed connections. #' #' #' @@ -13,18 +12,15 @@ #' @usage #' macaque #' @format -#' A directed `igraph` graph object with vertex attributes -#' \sQuote{name} and \sQuote{shape}. +#' A directed `igraph` graph object with vertex attributes \sQuote{name} and \sQuote{shape}. #' -#' This dataset is licensed under a Creative Commons -#' Attribution-Share Alike 2.0 UK: England & Wales License, +#' This dataset is licensed under a Creative Commons Attribution-Share Alike 2.0 UK: England & Wales License, #' see for details. #' Please cite the reference below if you use this dataset. -#' @references Negyessy L., Nepusz T., Kocsis L., Bazso F.: Prediction of -#' the main cortical areas and connections involved in the tactile -#' function of the visual cortex by network analysis. *European -#' Journal of Neuroscience*, 23(7): 1919-1930, 2006. -#' \doi{10.1111/j.1460-9568.2006.04678.x} +#' @references Negyessy L., Nepusz T., Kocsis L., Bazso F.: +#' Prediction of the main cortical areas and connections involved in the tactile function of the visual cortex by network analysis. +#' *European Journal of Neuroscience*, 23(7): 1919-1930, 2006. +#' \doi{10.1111/j.1460-9568.2006.04678.x} #' @source See reference below. #' @keywords datasets NULL diff --git a/R/netzschleuder.R b/R/netzschleuder.R index b431457..a99d514 100644 --- a/R/netzschleuder.R +++ b/R/netzschleuder.R @@ -78,11 +78,12 @@ download_file <- function(zip_url, token = NULL, file, size_limit) { #' \item{`ns_graph()`}{creates an `igraph` object directly from Netzschleuder.} #' } #' -#' @param name Character. The name of the network dataset. To get a network from a collection, -#' use the format `/`. +#' @param name Character. The name of the network dataset. +#' To get a network from a collection, use the format `/`. #' @param collection Logical. If TRUE, get the metadata of a whole collection of networks. #' @param token Character. Some networks have restricted access and require a token. -#' @param size_limit Numeric. Maximum allowed file size in GB. Larger files will be prevented from being downloaded. +#' @param size_limit Numeric. Maximum allowed file size in GB. +#' Larger files will be prevented from being downloaded. #' See . #' #' @return diff --git a/R/rfid.R b/R/rfid.R index bc0d201..565a465 100644 --- a/R/rfid.R +++ b/R/rfid.R @@ -2,14 +2,11 @@ #' #' @description #' -#' Records of contacts among patients and various types of health care -#' workers in the geriatric unit of a hospital in Lyon, France, in -#' 2010, from 1pm on Monday, December 6 to 2pm on Friday, December -#' 10. Each of the 75 people in this study consented to wear RFID -#' sensors on small identification badges during this period, which made -#' it possible to record when any two of them were in face-to-face -#' contact with each other (i.e., within 1-1.5 m of each other) during -#' a 20-second interval of time. +#' Records of contacts among patients and various types of health care workers in the geriatric unit of a hospital in Lyon, France, +#' in 2010, from 1pm on Monday, December 6 to 2pm on Friday, December 10. +#' Each of the 75 people in this study consented to wear RFID sensors on small identification badges during this period, +#' which made it possible to record when any two of them were in face-to-face contact with each other +#' (i.e., within 1-1.5 m of each other) during a 20-second interval of time. #' #' #' @@ -18,21 +15,18 @@ #' @usage #' rfid #' @format -#' An igraph graph with graph attributes \sQuote{name} and -#' \sQuote{Citation}, vertex attribute \sQuote{Status} and edge attribute -#' \sQuote{Time}. +#' An igraph graph with graph attributes \sQuote{name} and \sQuote{Citation}, +#' vertex attribute \sQuote{Status} and edge attribute \sQuote{Time}. #' -#' \sQuote{Status} is the status of the person. Status codes: -#' administrative staff (ADM), medical doctor (MED), paramedical staff, -#' such as nurses or nurses' aides (NUR), and patients (PAT). +#' \sQuote{Status} is the status of the person. +#' Status codes: administrative staff (ADM), medical doctor (MED), +#' paramedical staff, such as nurses or nurses' aides (NUR), and patients (PAT). #' -#' \sQuote{Time} is the time of the encounter, it is the second when the -#' 20 second encounter terminated. +#' \sQuote{Time} is the time of the encounter, it is the second when the 20 second encounter terminated. #' @references -#' P. Vanhems, A. Barrat, C. Cattuto, J.-F. Pinton, N. Khanafer, -#' C. Regis, B.-a. Kim, B. Comte, N. Voirin: Estimating potential -#' infection transmission routes in hospital wards using wearable -#' proximity sensors. PloS One 8(9), e73970 306 (2013). +#' P. Vanhems, A. Barrat, C. Cattuto, J.-F. Pinton, N. Khanafer, C. Regis, B.-a. Kim, B. Comte, N. Voirin: +#' Estimating potential infection transmission routes in hospital wards using wearable proximity sensors. +#' PloS One 8(9), e73970 306 (2013). #' \doi{10.1371/journal.pone.0073970} #' @source #' See the reference below. diff --git a/R/yeast.R b/R/yeast.R index 3628b6d..9583f49 100644 --- a/R/yeast.R +++ b/R/yeast.R @@ -2,13 +2,11 @@ #' #' @description #' -#' Comprehensive protein-protein interaction maps promise to reveal many -#' aspects of the complex regulatory network underlying cellular -#' function. +#' Comprehensive protein-protein interaction maps promise to reveal many aspects +#' of the complex regulatory network underlying cellular function. #' -#' This data set was compiled by von Mering et al. (see reference below), -#' combining various sources. Only the interactions that have -#' \sQuote{high} and \sQuote{medium} confidence are included here. +#' This data set was compiled by von Mering et al. (see reference below), combining various sources. +#' Only the interactions that have \sQuote{high} and \sQuote{medium} confidence are included here. #' #' #' @@ -17,50 +15,38 @@ #' @usage #' yeast #' @format -#' An undirected `igraph` graph object. Its graph attributes: -#' \sQuote{name}, \sQuote{Citation}, \sQuote{Author}, -#' \sQuote{URL}. \sQuote{Classes}. The \sQuote{Classes} -#' attribute contain the key for the classification labels of the -#' proteins, in a data frame, the original MIPS categories are given -#' after the semicolon: +#' An undirected `igraph` graph object. +#' Its graph attributes: \sQuote{name}, \sQuote{Citation}, \sQuote{Author}, \sQuote{URL}. \sQuote{Classes}. +#' The \sQuote{Classes} attribute contain the key for the classification labels of the proteins, +#' in a data frame, the original MIPS categories are given after the semicolon: #' \describe{ #' \item{E}{energy production; energy} #' \item{G}{aminoacid metabolism; aminoacid metabolism} #' \item{M}{other metabolism; all remaining metabolism categories} #' \item{P}{translation; protein synthesis} -#' \item{T}{transcription; transcription, but without subcategory -#' \sQuote{transcriptional control}} -#' \item{B}{transcriptional control; subcategory -#' \sQuote{transcriptional control}} -#' \item{F}{protein fate; protein fate (folding, modification, -#' destination)} -#' \item{O}{cellular organization; cellular transport and transport -#' mechanisms} -#' \item{A}{transport and sensing; categories \sQuote{transport -#' facilitation} and \sQuote{regulation of / interaction with -#' cellular environment}} +#' \item{T}{transcription; transcription, but without subcategory \sQuote{transcriptional control}} +#' \item{B}{transcriptional control; subcategory \sQuote{transcriptional control}} +#' \item{F}{protein fate; protein fate (folding, modification, destination)} +#' \item{O}{cellular organization; cellular transport and transport mechanisms} +#' \item{A}{transport and sensing; +#' categories \sQuote{transport facilitation} and \sQuote{regulation of / interaction with cellular environment}} #' \item{R}{stress and defense; cell rescue, defense and virulence} #' \item{D}{genome maintenance; DNA processing and cell cycle} -#' \item{C}{cellular fate / organization; categories \sQuote{cell fate} -#' and \sQuote{cellular communication / signal transduction} and -#' \sQuote{control of cellular organization}} -#' \item{U}{uncharacterized; categories \sQuote{not yet clear-cut} and -#' \sQuote{uncharacterized}} +#' \item{C}{cellular fate / organization; +#' categories \sQuote{cell fate} and \sQuote{cellular communication / signal transduction} +#' and \sQuote{control of cellular organization}} +#' \item{U}{uncharacterized; categories \sQuote{not yet clear-cut} and \sQuote{uncharacterized}} #' } #' -#' Vertex attributes: \sQuote{name}, \sQuote{Description}, -#' \sQuote{Class}, the last one contains the class of the protein, -#' accoring to the classification above. +#' Vertex attributes: \sQuote{name}, \sQuote{Description}, \sQuote{Class}, +#' the last one contains the class of the protein, accoring to the classification above. #' -#' Note that some proteins in the network did not appear in the -#' annotation files, the \sQuote{Class} and \sQuote{Description} -#' attributes are `NA` for these. +#' Note that some proteins in the network did not appear in the annotation files, +#' the \sQuote{Class} and \sQuote{Description} attributes are `NA` for these. #' @references -#' Comparative assessment of large-scale data sets of protein-protein -#' interactions. Christian von Mering, Roland Krause, Berend Snel, -#' Michael Cornell, Stephen G. Oliver, Stanley Fields and Peer -#' Bork. *Nature* 417, 399-403 (2002) -#' @source The data was downloaded from -#' . +#' Comparative assessment of large-scale data sets of protein-protein interactions. +#' Christian von Mering, Roland Krause, Berend Snel, Michael Cornell, Stephen G. Oliver, Stanley Fields and Peer Bork. +#' *Nature* 417, 399-403 (2002) +#' @source The data was downloaded from . #' @keywords datasets NULL diff --git a/README.Rmd b/README.Rmd index 9ce3198..c5543bc 100644 --- a/README.Rmd +++ b/README.Rmd @@ -3,9 +3,8 @@ output: cynkratemplate::readme_document --- + Edit that file and render it the usual way: rmarkdown::render(), devtools::build_readme(), or the Knit button. + The cynkratemplate package must be installed; it supplies the output format. --> ```{r, setup, echo = FALSE, message = FALSE} @@ -39,8 +38,8 @@ From CRAN: install.packages("igraphdata") ``` -You can install the development version from Github, using the -[devtools package](https://github.com/hadley/devtools): +You can install the development version from Github, +using the [devtools package](https://github.com/hadley/devtools): ```{r, eval = FALSE} devtools::install_github("igraph/igraphdata") @@ -57,5 +56,4 @@ data(package = "igraphdata") # License -CC BY-SA 4.0, plus see [LICENSE](LICENSE) for the licenses of the -individual data sets. +CC BY-SA 4.0, plus see [LICENSE](LICENSE) for the licenses of the individual data sets. diff --git a/README.md b/README.md index f593f2e..92c3744 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ + Edit that file and render it the usual way: rmarkdown::render(), devtools::build_readme(), or the Knit button. + The cynkratemplate package must be installed; it supplies the output format. --> # Data sets for the igraph R package @@ -20,8 +19,8 @@ From CRAN: install.packages("igraphdata") ``` -You can install the development version from Github, using the -[devtools package](https://github.com/hadley/devtools): +You can install the development version from Github, +using the [devtools package](https://github.com/hadley/devtools): ``` r devtools::install_github("igraph/igraphdata") @@ -52,5 +51,4 @@ data(package = "igraphdata") # License -CC BY-SA 4.0, plus see [LICENSE](LICENSE) for the licenses of the -individual data sets. +CC BY-SA 4.0, plus see [LICENSE](LICENSE) for the licenses of the individual data sets. diff --git a/index.md b/index.md index c35c4b6..e09c2ec 100644 --- a/index.md +++ b/index.md @@ -1,9 +1,8 @@ + Edit that file and render it the usual way: rmarkdown::render(), devtools::build_readme(), or the Knit button. + The cynkratemplate package must be installed; it supplies the output format. --> # Data sets for the igraph R package @@ -21,8 +20,8 @@ From CRAN: install.packages("igraphdata") ``` -You can install the development version from Github, using the -[devtools package](https://github.com/hadley/devtools): +You can install the development version from Github, +using the [devtools package](https://github.com/hadley/devtools): ``` r devtools::install_github("igraph/igraphdata") diff --git a/man/Koenigsberg.Rd b/man/Koenigsberg.Rd index 680b522..cb2e263 100644 --- a/man/Koenigsberg.Rd +++ b/man/Koenigsberg.Rd @@ -5,35 +5,29 @@ \alias{Koenigsberg} \title{Bridges of Koenigsberg from Euler's times} \format{ -An undirected \code{igraph} graph object with vertex attributes -\sQuote{name} and \sQuote{Euler_letter}, the latter is the notation -from Eulers original paper; and edge attributes \sQuote{name} (the name -of the bridge) and \sQuote{Euler_letter}, again, Euler's notation -from his paper. +An undirected \code{igraph} graph object with vertex attributes \sQuote{name} and \sQuote{Euler_letter}, +the latter is the notation from Eulers original paper; +and edge attributes \sQuote{name} (the name of the bridge) and \sQuote{Euler_letter}, again, Euler's notation from his paper. This dataset is in the public domain. } \source{ -Wikipedia, -\url{https://en.wikipedia.org/wiki/Seven_Bridges_of_K\%C3\%B6nigsberg} +Wikipedia, \url{https://en.wikipedia.org/wiki/Seven_Bridges_of_K\%C3\%B6nigsberg} } \usage{ Koenigsberg } \description{ -The Seven Bridges of Koenigsberg is a notable historical problem in -mathematics. Its negative resolution by Leonhard Euler in 1735 laid -the foundations of graph theory and presaged the idea of topology. +The Seven Bridges of Koenigsberg is a notable historical problem in mathematics. +Its negative resolution by Leonhard Euler in 1735 laid the foundations of graph theory and presaged the idea of topology. -The city of Koenigsberg in Prussia (now Kaliningrad, Russia) was set on -both sides of the Pregel River, and included two large islands which -were connected to each other and the mainland by seven bridges +The city of Koenigsberg in Prussia (now Kaliningrad, Russia) was set on both sides of the Pregel River, +and included two large islands which were connected to each other and the mainland by seven bridges -The problem was to find a walk through the city that would cross each -bridge once and only once. The islands could not be reached by any route -other than the bridges, and every bridge must have been crossed -completely every time (one could not walk half way onto the bridge and -then turn around and later cross the other half from the other side). +The problem was to find a walk through the city that would cross each bridge once and only once. +The islands could not be reached by any route other than the bridges, +and every bridge must have been crossed completely every time +(one could not walk half way onto the bridge and then turn around and later cross the other half from the other side). Euler proved that the problem has no solution. } diff --git a/man/UKfaculty.Rd b/man/UKfaculty.Rd index 0965cbd..04c2215 100644 --- a/man/UKfaculty.Rd +++ b/man/UKfaculty.Rd @@ -5,12 +5,10 @@ \alias{UKfaculty} \title{Friendship network of a UK university faculty} \format{ -A directed \code{igraph} graph object with vertex attribute -\sQuote{Group}, the numeric id of the school affiliation, and edge -attribute \sQuote{weight}, i.e. the graph is weighted. +A directed \code{igraph} graph object with vertex attribute \sQuote{Group}, the numeric id of the school affiliation, +and edge attribute \sQuote{weight}, i.e. the graph is weighted. -This dataset is licensed under a Creative Commons -Attribution-Share Alike 2.0 UK: England & Wales License, +This dataset is licensed under a Creative Commons Attribution-Share Alike 2.0 UK: England & Wales License, see \url{http://creativecommons.org/licenses/by-sa/2.0/uk/} for details. Please cite the reference below if you use this dataset. } @@ -21,16 +19,14 @@ See reference below. UKfaculty } \description{ -The personal friendship network of a faculty of a UK -university, consisting of 81 vertices (individuals) and 817 directed -and weighted connections. The school affiliation of each individual is -stored as a vertex attribute. This dataset can serve as a testbed for -community detection algorithms. +The personal friendship network of a faculty of a UK university, +consisting of 81 vertices (individuals) and 817 directed and weighted connections. +The school affiliation of each individual is stored as a vertex attribute. +This dataset can serve as a testbed for community detection algorithms. } \references{ -Nepusz T., Petroczi A., Negyessy L., Bazso F.: Fuzzy -communities and the concept of bridgeness in complex -networks. Physical Review E 77:016107, 2008. +Nepusz T., Petroczi A., Negyessy L., Bazso F.: Fuzzy communities and the concept of bridgeness in complex networks. +Physical Review E 77:016107, 2008. \doi{10.1103/PhysRevE.77.016107} } \keyword{datasets} diff --git a/man/USairports.Rd b/man/USairports.Rd index 0f72cc6..8a70d57 100644 --- a/man/USairports.Rd +++ b/man/USairports.Rd @@ -5,48 +5,40 @@ \alias{USairports} \title{US airport network, 2010 December} \format{ -A directed \code{igraph} graph object, with multiple edges. It has a -\sQuote{name} graph attribute, and several vertex and edge -attributes. The vertex attributes: +A directed \code{igraph} graph object, with multiple edges. +It has a \sQuote{name} graph attribute, and several vertex and edge attributes. +The vertex attributes: \describe{ -\item{name}{Symbolic vertex name, this is the three letter IATA -airport code.} +\item{name}{Symbolic vertex name, this is the three letter IATA airport code.} \item{City}{City and state, where the airport is located.} \item{Position}{Position of the airport, in WGS coordinates.} } Edge attributes: \describe{ -\item{Carrier}{Name of the airline. The network includes both -domestic and international carriers that performed at least one -flight in December of 2010.} -\item{Departures}{The number of departures (for a given airline and -aircraft type.} -\item{Seats}{The total number of seats available on the flights -carried out by a given airline, using a given aircraft type.} -\item{Passengers}{The total number of passangers on the flights -carried out by a given airline, using a given aircraft type.} +\item{Carrier}{Name of the airline. +The network includes both domestic and international carriers that performed at least one flight in December of 2010.} +\item{Departures}{The number of departures (for a given airline and aircraft type.} +\item{Seats}{The total number of seats available on the flights carried out by a given airline, using a given aircraft type.} +\item{Passengers}{The total number of passangers on the flights carried out by a given airline, using a given aircraft type.} \item{Aircraft}{Type of the aircraft.} \item{Distance}{The distance between the two airports, in miles.} } } \source{ -Most of this information was downloaded from The Research and -Innovative Technology Administration (RITA). See -\url{http://www.rita.dot.gov/about_rita/} for details. The airport -position information was collected from Wikipedia and other public -online sources. +Most of this information was downloaded from The Research and Innovative Technology Administration (RITA). +See \url{http://www.rita.dot.gov/about_rita/} for details. +The airport position information was collected from Wikipedia and other public online sources. } \usage{ USairports } \description{ -The network of passanger flights between airports in the United -States. The data set was compiled based on flights in 2010 -December. This network is directed and edge directions correspond to -flight directions. Each edge is specific to a single carrier aircraft -type. Multiple carriers between the same two airports are denoted by -multiple edges. +The network of passanger flights between airports in the United States. +The data set was compiled based on flights in 2010 December. +This network is directed and edge directions correspond to flight directions. +Each edge is specific to a single carrier aircraft type. +Multiple carriers between the same two airports are denoted by multiple edges. See information about the included meta-data below. } diff --git a/man/enron.Rd b/man/enron.Rd index 747bd01..abaab97 100644 --- a/man/enron.Rd +++ b/man/enron.Rd @@ -8,11 +8,9 @@ A directed \code{igraph} graph object. Graph attributes: \itemize{ -\item \sQuote{LDC_names} The names of the 32 LDC catagories the emails -are classfied into by Michael W. Berry +\item \sQuote{LDC_names} The names of the 32 LDC catagories the emails are classfied into by Michael W. Berry (\url{http://www.cis.jhu.edu/~parky/Enron/Anno_Topic_exp_LDC.pdf}) -\item \sQuote{LDC_desc} Longer descriptions of the 32 LDC -categories. +\item \sQuote{LDC_desc} Longer descriptions of the 32 LDC categories. \item \sQuote{Citation} Additionally, see also the references below. \item \sQuote{name} } @@ -24,22 +22,18 @@ Vertex attributes: \itemize{ } Edge attributes: \itemize{ -\item \sQuote{Time} When the email was sent. Note that some time -labels are from 1979, these are certainly wrong and you might want -to remove them before analyses that include time. -\item \sQuote{Reciptype} Recipient type, \sQuote{to}, \sQuote{cc} or -\sQuote{bcc}. -\item \sQuote{Topic} Assigned based on 3-means clustering of -randomly selected 3,120 out of all 125,409 messages, then NN -classification for the whole corpus. Note that topic 0 means an -outlier, e.g., too few words or all meaningless numbers in the -message body. -\item \sQuote{LDC_topic} Assigned based on Michael W. Berry's 2001 -\dQuote{Annotated (by Topic) Enron Email Data Set.} +\item \sQuote{Time} When the email was sent. +Note that some time labels are from 1979, +these are certainly wrong and you might want to remove them before analyses that include time. +\item \sQuote{Reciptype} Recipient type, \sQuote{to}, \sQuote{cc} or \sQuote{bcc}. +\item \sQuote{Topic} Assigned based on 3-means clustering of randomly selected 3,120 out of all 125,409 messages, +then NN classification for the whole corpus. +Note that topic 0 means an outlier, e.g., too few words or all meaningless numbers in the message body. +\item \sQuote{LDC_topic} Assigned based on Michael W. Berry's 2001 \dQuote{Annotated (by Topic) Enron Email Data Set.} (\url{http://www.cis.jhu.edu/~parky/Enron/Anno_Topic_exp_LDC.pdf}) -There are 32 topics. Topic "0" means an outlier, e.g., too few words -or all meaningless numbers in the message body, etc. Topic "-1" -means there is no matching topic. +There are 32 topics. +Topic "0" means an outlier, e.g., too few words or all meaningless numbers in the message body, etc. +Topic "-1" means there is no matching topic. } } \source{ @@ -53,39 +47,31 @@ An Enron email dataset has been made public by the U.S. Department of Justice. } \references{ C.E. Priebe, J.M. Conroy, D.J. Marchette, and Y. Park, -Scan Statistics on Enron Graphs Computational and Mathematical -Organization Theory, Volume 11, Number 3, p229 - 247, October 2005, -Springer Science+Business Media B.V. \doi{10.1007/s10588-005-5378-z} +Scan Statistics on Enron Graphs Computational and Mathematical Organization Theory, +Volume 11, Number 3, p229 - 247, October 2005, Springer Science+Business Media B.V. \doi{10.1007/s10588-005-5378-z} C.E. Priebe, J.M. Conroy, D.J. Marchette, and Y. Park, -Scan Statistics on Enron Graphs, SIAM International Conference on -Data Mining, Workshop on Link Analysis, Counterterrorism and Security, -Newport Beach, California, April 23, 2005. +Scan Statistics on Enron Graphs, SIAM International Conference on Data Mining, +Workshop on Link Analysis, Counterterrorism and Security, Newport Beach, California, April 23, 2005. -Gina Kolata, Enron Offers an Unlikely Boost to E-Mail Surveillance, -New York Times, Week in Review, May 22, 2005. +Gina Kolata, Enron Offers an Unlikely Boost to E-Mail Surveillance, New York Times, Week in Review, May 22, 2005. -C.E. Priebe, Scan Statistics on Enron Graphs, IPAM Summer Graduate -School: Intelligent Extraction of Information from Graphs and High -Dimensional Data, UCLA, July 11-29, 2005. +C.E. Priebe, Scan Statistics on Enron Graphs, +IPAM Summer Graduate School: Intelligent Extraction of Information from Graphs and High Dimensional Data, +UCLA, July 11-29, 2005. -C.E. Priebe, Scan Statistics on Enron Graphs, 2005 Fall Department -of Applied Mathematics and Statistics Seminars, September 15, 2005, -The Johns Hopkins University. +C.E. Priebe, Scan Statistics on Enron Graphs, +2005 Fall Department of Applied Mathematics and Statistics Seminars, September 15, 2005, The Johns Hopkins University. -Y. Park, C.E. Priebe, D.J. Marchette, Scan Statistics on Enron -Hypergraphs, Interface 2008, Durham, North Carolina, May 21, 2008, +Y. Park, C.E. Priebe, D.J. Marchette, Scan Statistics on Enron Hypergraphs, Interface 2008, Durham, North Carolina, May 21, 2008, -Y. Park, C.E. Priebe, D.J. Marchette, Anomaly Detection using Scan -Statistics on Enron Graphs and Hypergraphs, The Satellite Workshop of -the IASC 2008 Conference, Seoul, Korea, December 1-3, 2008. +Y. Park, C.E. Priebe, D.J. Marchette, Anomaly Detection using Scan Statistics on Enron Graphs and Hypergraphs, +The Satellite Workshop of the IASC 2008 Conference, Seoul, Korea, December 1-3, 2008. -Y. Park, C.E. Priebe, D.J. Marchette, A. Youssef, Anomaly Detection -using Scan Statistics on Time Series of Hypergraphs, Workshop on Link -Analysis, Counterterrorism and Security at the SIAM International -Conference on Data Mining, Sparks, Nevada, May 1-3, 2009, +Y. Park, C.E. Priebe, D.J. Marchette, A. Youssef, Anomaly Detection using Scan Statistics on Time Series of Hypergraphs, +Workshop on Link Analysis, Counterterrorism and Security at the SIAM International Conference on Data Mining, +Sparks, Nevada, May 1-3, 2009, -Y. Park, C.E. Priebe, A. Youssef, Anomaly Detection in Time Series of -Graphs using Fusion of Invariants, Computational and Mathematical -Organization Theory, submitted, 2010. \doi{10.1109/JSTSP.2012.2233712} +Y. Park, C.E. Priebe, A. Youssef, Anomaly Detection in Time Series of Graphs using Fusion of Invariants, +Computational and Mathematical Organization Theory, submitted, 2010. \doi{10.1109/JSTSP.2012.2233712} } diff --git a/man/foodwebs.Rd b/man/foodwebs.Rd index df0948b..9953028 100644 --- a/man/foodwebs.Rd +++ b/man/foodwebs.Rd @@ -5,13 +5,12 @@ \alias{foodwebs} \title{A collection of food webs} \format{ -A named list of directed \code{igraph} graph objects. Here are the -list of the graphs included: +A named list of directed \code{igraph} graph objects. +Here are the list of the graphs included: \describe{ \item{\sQuote{ChesLower}}{Lower Chesapeake Bay in Summer. -Reference: Hagy, J.D. (2002) Eutrophication, hypoxia and -trophic transfer efficiency in Chesapeake Bay PhD Dissertation, +Reference: Hagy, J.D. (2002) Eutrophication, hypoxia and trophic transfer efficiency in Chesapeake Bay PhD Dissertation, University of Maryland at College Park (USA), 446 pp.} \item{\sQuote{ChesMiddle}}{Middle Chesapeake Bay in Summer. @@ -21,95 +20,77 @@ Reference: same as for \sQuote{ChesLower}.} Reference: same as for \sQuote{ChesLower}.} \item{\sQuote{Chesapeake}}{Chesapeake Bay Mesohaline Network. -Reference: Baird D. & Ulanowicz R.E. (1989) The seasonal dynamics -of the Chesapeake Bay ecosystem. Ecological Monographs 59:329-364.} +Reference: Baird D. & Ulanowicz R.E. (1989) The seasonal dynamics of the Chesapeake Bay ecosystem. +Ecological Monographs 59:329-364.} \item{\sQuote{CrystalC}}{Crystal River Creek (Control). -Reference: Homer, M. and W.M. Kemp. Unpublished Ms. See also -Ulanowicz, R.E. 1986. Growth and Development: Ecosystems -Phenomenology. Springer, New York. pp 69-79.} +Reference: Homer, M. and W.M. Kemp. Unpublished Ms. +See also Ulanowicz, R.E. 1986. Growth and Development: Ecosystems Phenomenology. Springer, New York. pp 69-79.} \item{\sQuote{CrystalD}}{Crystal River Creek (Delta Temp). Reference: same as for \sQuote{CrystalD}.} \item{\sQuote{Maspalomas}}{Charca de Maspalomas. -Reference: Almunia, J., G. Basterretxea, J. Aristegui, and -R.E. Ulanowicz. (1999) Benthic- Pelagic switching in a coastal -subtropical lagoon. Estuarine, Coastal and Shelf Science -49:363-384.} +Reference: Almunia, J., G. Basterretxea, J. Aristegui, and R.E. Ulanowicz. (1999) +Benthic- Pelagic switching in a coastal subtropical lagoon. Estuarine, Coastal and Shelf Science 49:363-384.} \item{\sQuote{Michigan}}{Lake Michigan Control network. -Reference: Krause, A. and D. Mason. (In preparation.) A. Krause, -PhD. Dissertation, Michigan State University. Ann Arbor, MI. USA.} +Reference: Krause, A. and D. Mason. (In preparation.) +A. Krause, PhD. Dissertation, Michigan State University. Ann Arbor, MI. USA.} \item{\sQuote{Mondego}}{Mondego Estuary - Zostrea site. -Reference: Patricio, J. (In Preparation) Master's -Thesis. University of Coimbra, Coimbra, Portugal.} +Reference: Patricio, J. (In Preparation) Master's Thesis. University of Coimbra, Coimbra, Portugal.} \item{\sQuote{Narragan}}{Narragansett Bay Model. -Reference: Monaco, M.E. and R.E. Ulanowicz. (1997) Comparative -ecosystem trophic structure of three U.S. Mid-Atlantic -estuaries. Mar. Ecol. Prog. Ser. 161:239-254.} +Reference: Monaco, M.E. and R.E. Ulanowicz. (1997) Comparative ecosystem trophic structure of three U.S. Mid-Atlantic estuaries. +Mar. Ecol. Prog. Ser. 161:239-254.} \item{\sQuote{Rhode}}{Rhode River Watershed - Water Budget. -Reference: Correll, D. (Unpublished manuscript) Smithsonian -Institute, Chesapeake Bay Center for Environmental Research, +Reference: Correll, D. (Unpublished manuscript) Smithsonian Institute, Chesapeake Bay Center for Environmental Research, Edgewater, Maryland 21037-0028 USA.} \item{\sQuote{StMarks}}{St. Marks River (Florida) Flow network. Reference: Baird, D., J. Luczkovich and R. R. Christian. (1998) -Assessment of spatial and temporal variability in ecosystem -attributes of the St Marks National Wildlife Refuge, Apalachee Bay, -Florida. Estuarine, Coastal, and Shelf Science 47: 329-349.} +Assessment of spatial and temporal variability in ecosystem attributes of the St Marks National Wildlife Refuge, +Apalachee Bay, Florida. Estuarine, Coastal, and Shelf Science 47: 329-349.} \item{\sQuote{baydry}}{Florida Bay Trophic Exchange Matrix, dry season. -Reference: Ulanowicz, R. E., C. Bondavalli, and -M. S. Egnotovich. 1998. Network analysis of trophic dynamics in -South Florida ecosystems, FY 97: the Florida Bay ecosystem. Annual -Report to the United States Geological Service Biological Resources -Division, University of Miami Coral Gables, [UM-CES] CBL 98-123, -Maryland System Center for Environmental Science, Chesapeake -Biological Laboratory, Maryland, USA.} +Reference: Ulanowicz, R. E., C. Bondavalli, and M. S. Egnotovich. 1998. +Network analysis of trophic dynamics in South Florida ecosystems, FY 97: the Florida Bay ecosystem. +Annual Report to the United States Geological Service Biological Resources Division, University of Miami Coral Gables, +[UM-CES] CBL 98-123, Maryland System Center for Environmental Science, Chesapeake Biological Laboratory, Maryland, USA.} \item{\sQuote{baywet}}{Florida Bay Trophic Exchange Matrix, wet season. Reference: same as for \sQuote{baydry}.} \item{\sQuote{cypdry}}{Cypress, dry season. -Reference: Ulanowicz, R. E., C. Bondavalli, and -M. S. Egnotovich. 1997. Network analysis of trophic dynamics in -South Florida ecosystems, FY 96: the cypress wetland -ecosystem. Annual Report to the United States Geological Service -Biological Resources Division, University of Miami Coral Gables, -[UM-CES] CBL 97-075, Maryland System Center for Environmental -Science, Chesapeake Biological Laboratory.} +Reference: Ulanowicz, R. E., C. Bondavalli, and M. S. Egnotovich. 1997. +Network analysis of trophic dynamics in South Florida ecosystems, FY 96: the cypress wetland ecosystem. +Annual Report to the United States Geological Service Biological Resources Division, University of Miami Coral Gables, +[UM-CES] CBL 97-075, Maryland System Center for Environmental Science, Chesapeake Biological Laboratory.} \item{\sQuote{cypwet}}{Cypress, wet season. Reference: same as for \sQuote{cypdry}.} \item{\sQuote{gramdry}}{Everglades Graminoids - Dry Season. -Reference: Ulanowicz, R. E., J. J. Heymans, and -M. S. Egnotovich. 2000. Network analysis of trophic dynamics in -South Florida ecosystems, FY 99: the graminoid ecosystem. Technical -Report TS-191-99, Maryland System Center for Environmental Science, -Chesapeake Biological Laboratory, Maryland, USA.} +Reference: Ulanowicz, R. E., J. J. Heymans, and M. S. Egnotovich. 2000. +Network analysis of trophic dynamics in South Florida ecosystems, FY 99: the graminoid ecosystem. +Technical Report TS-191-99, Maryland System Center for Environmental Science, Chesapeake Biological Laboratory, Maryland, USA.} \item{\sQuote{gramwet}}{Everglades Graminoids - Wet Season. Reference: same as for \sQuote{gramdry}.} \item{\sQuote{mangdry}}{Mangrove Estuary, Dry Season. -Reference: Ulanowicz, R. E., C. Bondavalli, J. J. Heymans, and -M. S. Egnotovich. 1999. Network analysis of trophic dynamics in -South Florida ecosystems, FY 98: the mangrove ecosystem. Technical -Report TS-191-99, Maryland System Center for Environmental Science, -Chesapeake Biological Laboratory, Maryland, USA.} +Reference: Ulanowicz, R. E., C. Bondavalli, J. J. Heymans, and M. S. Egnotovich. 1999. +Network analysis of trophic dynamics in South Florida ecosystems, FY 98: the mangrove ecosystem. +Technical Report TS-191-99, Maryland System Center for Environmental Science, Chesapeake Biological Laboratory, Maryland, USA.} \item{\sQuote{mangwet}}{Mangrove Estuary, Wet Season. Reference: same as for \sQuote{mangdry}.} } -Each graph has the following vertex attributes: \sQuote{name} is the -name of the species, \sQuote{ECO} is the type of the node, and -integer value between one and five, meaning: +Each graph has the following vertex attributes: \sQuote{name} is the name of the species, +\sQuote{ECO} is the type of the node, and integer value between one and five, meaning: \enumerate{ \item Living/producing compartment \item Other compartment @@ -117,27 +98,22 @@ integer value between one and five, meaning: \item Output \item Respiration. } -The \sQuote{Biomass} vertex attribute contains the biomass of the -species. +The \sQuote{Biomass} vertex attribute contains the biomass of the species. -Edges are weighted, and the weights denote energy flux between the -species involved. +Edges are weighted, and the weights denote energy flux between the species involved. -The graphs also contain some informative graph attributes: -\sQuote{Author}, \sQuote{Citation}, \sQuote{URL}, and -\sQuote{name}. +The graphs also contain some informative graph attributes: \sQuote{Author}, \sQuote{Citation}, \sQuote{URL}, and \sQuote{name}. } \source{ -See references for the individual webs above. The data itself -was downloaded from -\url{http://vlado.fmf.uni-lj.si/pub/networks/data/bio/foodweb/foodweb.htm}. +See references for the individual webs above. +The data itself was downloaded from \url{http://vlado.fmf.uni-lj.si/pub/networks/data/bio/foodweb/foodweb.htm}. } \usage{ foodwebs } \description{ -A list of graphs. Each one is a food web, i.e. a directed -graph of predator-prey relationships. +A list of graphs. +Each one is a food web, i.e. a directed graph of predator-prey relationships. } \references{ See them above. diff --git a/man/immuno.Rd b/man/immuno.Rd index e8e0e08..1d7a2ab 100644 --- a/man/immuno.Rd +++ b/man/immuno.Rd @@ -16,15 +16,14 @@ See reference below. immuno } \description{ -The undirected and connected network of interactions -in the immunoglobulin protein. It is made up of 1316 vertices -representing amino-acids and an edge is drawn between two -amino-acids if the shortest distance between their C_alpha atoms -is smaller than the threshold value \eqn{\theta=8}{theta=8} Angstrom. +The undirected and connected network of interactions in the immunoglobulin protein. +It is made up of 1316 vertices representing amino-acids +and an edge is drawn between two amino-acids +if the shortest distance between their C_alpha atoms is smaller than the threshold value \eqn{\theta=8}{theta=8} Angstrom. } \references{ -D. Gfeller, Simplifying complex networks: from a clustering to a -coarse graining strategy, \emph{PhD Thesis EPFL}, no 3888, 2007. +D. Gfeller, Simplifying complex networks: from a clustering to a coarse graining strategy, +\emph{PhD Thesis EPFL}, no 3888, 2007. \url{http://library.epfl.ch/theses/?nr=3888} } \keyword{datasets} diff --git a/man/karate.Rd b/man/karate.Rd index 0c4dcf6..6db9ac7 100644 --- a/man/karate.Rd +++ b/man/karate.Rd @@ -5,13 +5,13 @@ \alias{karate} \title{Zachary's karate club network} \format{ -An undirected \code{igraph} graph object. Vertex no. 1 is Mr. Hi, -vertex no. 34 corresponds to John A. +An undirected \code{igraph} graph object. +Vertex no. 1 is Mr. Hi, vertex no. 34 corresponds to John A. Graph attributes: \sQuote{name}, \sQuote{Citation}, \sQuote{Author}. -Vertex attributes: \sQuote{name}, \sQuote{Faction}, \sQuote{color} is -the same as \sQuote{Faction}, \sQuote{label} are short labels for plotting. +Vertex attributes: \sQuote{name}, \sQuote{Faction}, \sQuote{color} is the same as \sQuote{Faction}, +\sQuote{label} are short labels for plotting. Edge attribute: \sQuote{weight}. } @@ -22,44 +22,36 @@ See reference below. karate } \description{ -Social network between members of a university karate club, led by -president John A. and karate instructor Mr. Hi (pseudonyms). +Social network between members of a university karate club, led by president John A. and karate instructor Mr. Hi (pseudonyms). -The edge weights are the number of common activities the club -members took part of. These activities were: +The edge weights are the number of common activities the club members took part of. +These activities were: \enumerate{ \item Association in and between academic classes at the university. -\item Membership in Mr. Hi's private karate studio on the east side -of the city where Mr. Hi taught nights as a part-time instructor. -\item Membership in Mr. Hi's private karate studio on the east side -of the city, where many of his supporters worked out on weekends. -\item Student teaching at the east-side karate studio referred to in -(2). This is different from (2) in that student teachers interacted -with each other, but were prohibited from interacting with their -students. -\item Interaction at the university rathskeller, located in the same -basement as the karate club's workout area. -\item Interaction at a student-oriented bar located across the -street from the university campus. -\item Attendance at open karate tournaments held through the area at -private karate studios. -\item Attendance at intercollegiate karate tournaments held at local -universities. Since both open and intercollegiate tournaments were -held on Saturdays, attendance at both was impossible. +\item Membership in Mr. Hi's private karate studio on the east side of the city +where Mr. Hi taught nights as a part-time instructor. +\item Membership in Mr. Hi's private karate studio on the east side of the city, +where many of his supporters worked out on weekends. +\item Student teaching at the east-side karate studio referred to in (2). +This is different from (2) in that student teachers interacted with each other, +but were prohibited from interacting with their students. +\item Interaction at the university rathskeller, located in the same basement as the karate club's workout area. +\item Interaction at a student-oriented bar located across the street from the university campus. +\item Attendance at open karate tournaments held through the area at private karate studios. +\item Attendance at intercollegiate karate tournaments held at local universities. +Since both open and intercollegiate tournaments were held on Saturdays, attendance at both was impossible. } -Zachary studied conflict and fission in this network, as the karate -club was split into two separate clubs, after long disputes between -two factions of the club, one led by John A., the other by Mr. Hi. +Zachary studied conflict and fission in this network, +as the karate club was split into two separate clubs, after long disputes between two factions of the club, +one led by John A., the other by Mr. Hi. -The \sQuote{Faction} vertex attribute gives the faction memberships of -the actors. After the split of the club, club members chose their -new clubs based on their factions, except actor no. 9, who was in John -A.'s faction but chose Mr. Hi's club. +The \sQuote{Faction} vertex attribute gives the faction memberships of the actors. +After the split of the club, club members chose their new clubs based on their factions, +except actor no. 9, who was in John A.'s faction but chose Mr. Hi's club. } \references{ -Wayne W. Zachary. An Information Flow Model for Conflict and Fission -in Small Groups. \emph{Journal of Anthropological Research} Vol. 33, -No. 4 452-473 \doi{10.1086/jar.33.4.3629752} +Wayne W. Zachary. An Information Flow Model for Conflict and Fission in Small Groups. +\emph{Journal of Anthropological Research} Vol. 33, No. 4 452-473 \doi{10.1086/jar.33.4.3629752} } \keyword{datasets} diff --git a/man/kite.Rd b/man/kite.Rd index 6af187b..127a408 100644 --- a/man/kite.Rd +++ b/man/kite.Rd @@ -5,9 +5,8 @@ \alias{kite} \title{Krackhardt's kite} \format{ -An undirected igraph graph with graph attributes \code{name}, -\code{layout}, \code{Citation}, \code{Author}, \code{URL}, and vertex -attributes \code{label}, \code{name} and \code{Firstname}. +An undirected igraph graph with graph attributes \code{name}, \code{layout}, \code{Citation}, \code{Author}, \code{URL}, +and vertex attributes \code{label}, \code{name} and \code{Firstname}. } \source{ \url{http://www.orgnet.com/sna.html} @@ -17,13 +16,11 @@ kite } \description{ Krackhardt's kite is a fictional social network with ten actors. -It is a small (though not the smallest possible) graph for which the most -central actors are different according to the three classic centrality -measures: degree, closeness and betweenness. +It is a small (though not the smallest possible) graph for which the most central actors are different +according to the three classic centrality measures: degree, closeness and betweenness. } \references{ -Assessing the Political Landscape: Structure, Cognition, and Power in -Organizations. David Krackhardt. \emph{Admin. Sci. Quart.} 35, 342-369, -1990. \doi{10.2307/2393394} +Assessing the Political Landscape: Structure, Cognition, and Power in Organizations. +David Krackhardt. \emph{Admin. Sci. Quart.} 35, 342-369, 1990. \doi{10.2307/2393394} } \keyword{datasets} diff --git a/man/macaque.Rd b/man/macaque.Rd index 272e270..e8637eb 100644 --- a/man/macaque.Rd +++ b/man/macaque.Rd @@ -5,11 +5,9 @@ \alias{macaque} \title{Visuotactile brain areas and connections} \format{ -A directed \code{igraph} graph object with vertex attributes -\sQuote{name} and \sQuote{shape}. +A directed \code{igraph} graph object with vertex attributes \sQuote{name} and \sQuote{shape}. -This dataset is licensed under a Creative Commons -Attribution-Share Alike 2.0 UK: England & Wales License, +This dataset is licensed under a Creative Commons Attribution-Share Alike 2.0 UK: England & Wales License, see \url{http://creativecommons.org/licenses/by-sa/2.0/uk/} for details. Please cite the reference below if you use this dataset. } @@ -20,15 +18,13 @@ See reference below. macaque } \description{ -Graph model of the visuotactile brain areas and connections of the -macaque monkey. The model consists of 45 areas and 463 directed -connections. +Graph model of the visuotactile brain areas and connections of the macaque monkey. +The model consists of 45 areas and 463 directed connections. } \references{ -Negyessy L., Nepusz T., Kocsis L., Bazso F.: Prediction of -the main cortical areas and connections involved in the tactile -function of the visual cortex by network analysis. \emph{European -Journal of Neuroscience}, 23(7): 1919-1930, 2006. +Negyessy L., Nepusz T., Kocsis L., Bazso F.: +Prediction of the main cortical areas and connections involved in the tactile function of the visual cortex by network analysis. +\emph{European Journal of Neuroscience}, 23(7): 1919-1930, 2006. \doi{10.1111/j.1460-9568.2006.04678.x} } \keyword{datasets} diff --git a/man/netzschleuder.Rd b/man/netzschleuder.Rd index c5a30a0..5118cd0 100644 --- a/man/netzschleuder.Rd +++ b/man/netzschleuder.Rd @@ -13,14 +13,15 @@ ns_df(name, token = NULL, size_limit = 1) ns_graph(name, token = NULL, size_limit = 1) } \arguments{ -\item{name}{Character. The name of the network dataset. To get a network from a collection, -use the format \verb{/}.} +\item{name}{Character. The name of the network dataset. +To get a network from a collection, use the format \verb{/}.} \item{collection}{Logical. If TRUE, get the metadata of a whole collection of networks.} \item{token}{Character. Some networks have restricted access and require a token.} -\item{size_limit}{Numeric. Maximum allowed file size in GB. Larger files will be prevented from being downloaded. +\item{size_limit}{Numeric. Maximum allowed file size in GB. +Larger files will be prevented from being downloaded. See \url{https://networks.skewed.de/restricted}.} } \value{ diff --git a/man/rfid.Rd b/man/rfid.Rd index 1213831..84c5955 100644 --- a/man/rfid.Rd +++ b/man/rfid.Rd @@ -5,16 +5,14 @@ \alias{rfid} \title{Hospital encounter network data} \format{ -An igraph graph with graph attributes \sQuote{name} and -\sQuote{Citation}, vertex attribute \sQuote{Status} and edge attribute -\sQuote{Time}. +An igraph graph with graph attributes \sQuote{name} and \sQuote{Citation}, +vertex attribute \sQuote{Status} and edge attribute \sQuote{Time}. -\sQuote{Status} is the status of the person. Status codes: -administrative staff (ADM), medical doctor (MED), paramedical staff, -such as nurses or nurses' aides (NUR), and patients (PAT). +\sQuote{Status} is the status of the person. +Status codes: administrative staff (ADM), medical doctor (MED), +paramedical staff, such as nurses or nurses' aides (NUR), and patients (PAT). -\sQuote{Time} is the time of the encounter, it is the second when the -20 second encounter terminated. +\sQuote{Time} is the time of the encounter, it is the second when the 20 second encounter terminated. } \source{ See the reference below. @@ -24,20 +22,16 @@ Please cite it if you use this dataset in your work. rfid } \description{ -Records of contacts among patients and various types of health care -workers in the geriatric unit of a hospital in Lyon, France, in -2010, from 1pm on Monday, December 6 to 2pm on Friday, December -10. Each of the 75 people in this study consented to wear RFID -sensors on small identification badges during this period, which made -it possible to record when any two of them were in face-to-face -contact with each other (i.e., within 1-1.5 m of each other) during -a 20-second interval of time. +Records of contacts among patients and various types of health care workers in the geriatric unit of a hospital in Lyon, France, +in 2010, from 1pm on Monday, December 6 to 2pm on Friday, December 10. +Each of the 75 people in this study consented to wear RFID sensors on small identification badges during this period, +which made it possible to record when any two of them were in face-to-face contact with each other +(i.e., within 1-1.5 m of each other) during a 20-second interval of time. } \references{ -P. Vanhems, A. Barrat, C. Cattuto, J.-F. Pinton, N. Khanafer, -C. Regis, B.-a. Kim, B. Comte, N. Voirin: Estimating potential -infection transmission routes in hospital wards using wearable -proximity sensors. PloS One 8(9), e73970 306 (2013). +P. Vanhems, A. Barrat, C. Cattuto, J.-F. Pinton, N. Khanafer, C. Regis, B.-a. Kim, B. Comte, N. Voirin: +Estimating potential infection transmission routes in hospital wards using wearable proximity sensors. +PloS One 8(9), e73970 306 (2013). \doi{10.1371/journal.pone.0073970} } \keyword{datasets} diff --git a/man/yeast.Rd b/man/yeast.Rd index 4d5e484..a98d788 100644 --- a/man/yeast.Rd +++ b/man/yeast.Rd @@ -5,65 +5,51 @@ \alias{yeast} \title{Yeast protein interaction network} \format{ -An undirected \code{igraph} graph object. Its graph attributes: -\sQuote{name}, \sQuote{Citation}, \sQuote{Author}, -\sQuote{URL}. \sQuote{Classes}. The \sQuote{Classes} -attribute contain the key for the classification labels of the -proteins, in a data frame, the original MIPS categories are given -after the semicolon: +An undirected \code{igraph} graph object. +Its graph attributes: \sQuote{name}, \sQuote{Citation}, \sQuote{Author}, \sQuote{URL}. \sQuote{Classes}. +The \sQuote{Classes} attribute contain the key for the classification labels of the proteins, +in a data frame, the original MIPS categories are given after the semicolon: \describe{ \item{E}{energy production; energy} \item{G}{aminoacid metabolism; aminoacid metabolism} \item{M}{other metabolism; all remaining metabolism categories} \item{P}{translation; protein synthesis} -\item{T}{transcription; transcription, but without subcategory -\sQuote{transcriptional control}} -\item{B}{transcriptional control; subcategory -\sQuote{transcriptional control}} -\item{F}{protein fate; protein fate (folding, modification, -destination)} -\item{O}{cellular organization; cellular transport and transport -mechanisms} -\item{A}{transport and sensing; categories \sQuote{transport -facilitation} and \sQuote{regulation of / interaction with -cellular environment}} +\item{T}{transcription; transcription, but without subcategory \sQuote{transcriptional control}} +\item{B}{transcriptional control; subcategory \sQuote{transcriptional control}} +\item{F}{protein fate; protein fate (folding, modification, destination)} +\item{O}{cellular organization; cellular transport and transport mechanisms} +\item{A}{transport and sensing; +categories \sQuote{transport facilitation} and \sQuote{regulation of / interaction with cellular environment}} \item{R}{stress and defense; cell rescue, defense and virulence} \item{D}{genome maintenance; DNA processing and cell cycle} -\item{C}{cellular fate / organization; categories \sQuote{cell fate} -and \sQuote{cellular communication / signal transduction} and -\sQuote{control of cellular organization}} -\item{U}{uncharacterized; categories \sQuote{not yet clear-cut} and -\sQuote{uncharacterized}} +\item{C}{cellular fate / organization; +categories \sQuote{cell fate} and \sQuote{cellular communication / signal transduction} +and \sQuote{control of cellular organization}} +\item{U}{uncharacterized; categories \sQuote{not yet clear-cut} and \sQuote{uncharacterized}} } -Vertex attributes: \sQuote{name}, \sQuote{Description}, -\sQuote{Class}, the last one contains the class of the protein, -accoring to the classification above. +Vertex attributes: \sQuote{name}, \sQuote{Description}, \sQuote{Class}, +the last one contains the class of the protein, accoring to the classification above. -Note that some proteins in the network did not appear in the -annotation files, the \sQuote{Class} and \sQuote{Description} -attributes are \code{NA} for these. +Note that some proteins in the network did not appear in the annotation files, +the \sQuote{Class} and \sQuote{Description} attributes are \code{NA} for these. } \source{ -The data was downloaded from -\url{http://www.nature.com/nature/journal/v417/n6887/suppinfo/nature750.html}. +The data was downloaded from \url{http://www.nature.com/nature/journal/v417/n6887/suppinfo/nature750.html}. } \usage{ yeast } \description{ -Comprehensive protein-protein interaction maps promise to reveal many -aspects of the complex regulatory network underlying cellular -function. +Comprehensive protein-protein interaction maps promise to reveal many aspects +of the complex regulatory network underlying cellular function. -This data set was compiled by von Mering et al. (see reference below), -combining various sources. Only the interactions that have -\sQuote{high} and \sQuote{medium} confidence are included here. +This data set was compiled by von Mering et al. (see reference below), combining various sources. +Only the interactions that have \sQuote{high} and \sQuote{medium} confidence are included here. } \references{ -Comparative assessment of large-scale data sets of protein-protein -interactions. Christian von Mering, Roland Krause, Berend Snel, -Michael Cornell, Stephen G. Oliver, Stanley Fields and Peer -Bork. \emph{Nature} 417, 399-403 (2002) +Comparative assessment of large-scale data sets of protein-protein interactions. +Christian von Mering, Roland Krause, Berend Snel, Michael Cornell, Stephen G. Oliver, Stanley Fields and Peer Bork. +\emph{Nature} 417, 399-403 (2002) } \keyword{datasets} From 91819b8e089dad8ca2adf935b9864aba50d57953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kirill=20M=C3=BCller?= Date: Sat, 26 Sep 2026 22:24:35 +0200 Subject: [PATCH 04/10] fix(ci): Collect the fleet's workflow fixes after the move to central actions (cynkra/cynkratemplate#139) Co-authored-by: Claude Opus 5.5 Co-authored-by: Claude --- .github/workflows/R-CMD-check-dev.yaml | 2 +- .github/workflows/R-CMD-check.yaml | 12 ++++++++-- .github/workflows/fledge.yaml | 19 +++++++++++++-- .github/workflows/format-suggest.yaml | 33 ++++++++++---------------- 4 files changed, 41 insertions(+), 25 deletions(-) diff --git a/.github/workflows/R-CMD-check-dev.yaml b/.github/workflows/R-CMD-check-dev.yaml index f2d6b6d..7e3efa8 100644 --- a/.github/workflows/R-CMD-check-dev.yaml +++ b/.github/workflows/R-CMD-check-dev.yaml @@ -95,7 +95,7 @@ jobs: - name: Run R CMD check uses: cynkra/cynkratemplate/.github/actions/check@main with: - results: ${{ matrix.package }} + results: base R-CMD-check-dev: needs: diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index b45308a..e18d2fd 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -458,9 +458,17 @@ jobs: pkg <- "${{ matrix.package }}" pkgs <- tools::package_dependencies(pkg, reverse = TRUE)[[1]] installed <- rownames(utils::installed.packages()) - to_remove <- c(pkg, intersect(pkgs, installed)) + # `pkg` belongs inside the intersect() as much as its reverse + # dependencies do. It may legitimately not be installed -- a package + # pinned as `=?ignore` in Config/gha/extra-packages never is -- + # and then there is nothing to remove and nothing wrong: the job is + # checking that the package builds without it, which already holds. + to_remove <- intersect(c(pkg, pkgs), installed) print(to_remove) - remove.packages(to_remove) + # remove.packages() without `lib` looks only in .libPaths()[1] and + # errors on anything it does not find there, so name the library + # each package actually lives in. + for (p in to_remove) remove.packages(p, lib = dirname(find.package(p))) shell: Rscript {0} - name: Session info diff --git a/.github/workflows/fledge.yaml b/.github/workflows/fledge.yaml index 9ee5f55..3e041d9 100644 --- a/.github/workflows/fledge.yaml +++ b/.github/workflows/fledge.yaml @@ -158,11 +158,26 @@ jobs: git push -f origin "refs/tags/${tag}" fi - gh pr create --base "${BASE}" --head fledge --fill-first + # The pull request from an earlier night may still be open, when its + # checks never ran or never passed, and the force-push above has + # already moved it to this bump. `gh pr create` fails for a branch + # that has one, and under `set -e` that used to skip the two calls + # below -- the ones that get it checked and merged -- so it stayed + # open, and this step failed, every night after. Reuse it instead, + # retitled, since a squash merge takes its message from the title. + pr=$(gh pr list --head fledge --base "${BASE}" --state open --json url --jq '.[0].url // empty') + if [ -n "${pr}" ]; then + gh pr edit "${pr}" --title "$(git log -1 --format=%s)" --body "$(git log -1 --format=%b)" + else + pr=$(gh pr create --base "${BASE}" --head fledge --fill-first) + fi + # This one does have to be asked for. The pull request is gated on the # `rcc` status, and the branch push above raised no event either. gh workflow run rcc -f ref="$(git rev-parse HEAD)" - gh pr merge --squash --auto + if [ "$(gh pr view "${pr}" --json autoMergeRequest --jq '.autoMergeRequest != null')" != "true" ]; then + gh pr merge "${pr}" --squash --auto + fi shell: bash - name: Check release diff --git a/.github/workflows/format-suggest.yaml b/.github/workflows/format-suggest.yaml index 831392b..d3f6b70 100644 --- a/.github/workflows/format-suggest.yaml +++ b/.github/workflows/format-suggest.yaml @@ -11,15 +11,15 @@ on: # treated strictly as *data to be formatted*, never as code to run: # # 1. The formatter tooling (the `style` action and the air / clang-format - # binaries) is loaded from the BASE repository, not from the PR checkout. - # This is the critical point. The naive pattern + # binaries) is never loaded from the PR checkout. This is the critical + # point. The naive pattern # - uses: actions/checkout@v6 # ref: fork head - # - uses: cynkra/cynkratemplate/.github/actions/style@main + # - uses: ./.github/workflows/style # resolves `./...style` from the *checked-out fork*, so an attacker only # has to edit their fork's `style/action.yml` to run arbitrary commands - # with our token and secrets. Here we run the base repo's copy instead - # (checked out into `ci-base/`), so the fork controls only the input - # files, not the code that executes. + # with our token and secrets. A `owner/repo/path@ref` reference is + # fetched from that repository instead of from the workspace, so the + # fork controls only the input files, not the code that executes. # 2. air and clang-format merely parse and re-print source; they do not # evaluate it. No build / install / test step ever runs the fork's code. # 3. The PR is checked out with `persist-credentials: false`, so the token @@ -83,8 +83,8 @@ jobs: # The untrusted PR code, checked out at the workspace root. It is DATA # only -- nothing below executes it. `allow-unsafe-pr-checkout` is required # by actions/checkout@v6 for a fork ref under pull_request_target; it is - # safe here specifically because the code that runs comes from `ci-base/` - # (the base repo), never from this checkout. See the security notes above. + # safe here specifically because the code that runs is fetched from + # `cynkra/cynkratemplate`, never from this checkout. See the notes above. - name: Check out PR code (treated as data) uses: actions/checkout@v6 with: @@ -92,19 +92,12 @@ jobs: persist-credentials: false allow-unsafe-pr-checkout: true - # The trusted formatter tooling, from the base repository, into a separate - # directory the fork cannot influence. - - name: Check out trusted tooling from base repo - uses: actions/checkout@v6 - with: - path: ci-base - persist-credentials: false - - # Runs the base repo's `style` action against the PR code at the workspace - # root. Because the action comes from `ci-base/`, the fork controls only - # the files being formatted, not the code that runs. + # Runs the trusted `style` action against the PR code at the workspace + # root. Because the action is fetched from `cynkra/cynkratemplate` rather + # than resolved out of the workspace, the fork controls only the files + # being formatted, not the code that runs. - name: Format - uses: ./ci-base/.github/workflows/style + uses: cynkra/cynkratemplate/.github/actions/style@main - name: Suggest uses: reviewdog/action-suggester@2558ba17e65a9039e73764a73009fc05fef28a46 # v1 From a952befc8f89e1442b21103ca9acaf1024d16424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kirill=20M=C3=BCller?= Date: Sat, 26 Sep 2026 23:01:47 +0200 Subject: [PATCH 05/10] feat: Build a binary package in every check job and share it as an artifact (cynkra/cynkratemplate#141) Co-authored-by: Claude --- .github/workflows/R-CMD-check-dev.yaml | 19 ++++++++++++-- .github/workflows/R-CMD-check.yaml | 35 +++++++++++++++++++++----- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/.github/workflows/R-CMD-check-dev.yaml b/.github/workflows/R-CMD-check-dev.yaml index 7e3efa8..03dd190 100644 --- a/.github/workflows/R-CMD-check-dev.yaml +++ b/.github/workflows/R-CMD-check-dev.yaml @@ -57,6 +57,9 @@ jobs: name: base + outputs: + binary: ${{ steps.binary.outputs.artifact-name }} + permissions: # Both required by the update-snapshots action, which opens a pull # request with refreshed snapshots. @@ -78,7 +81,7 @@ jobs: with: cache-version: rcc-dev-base-1 needs: build, check - extra-packages: "any::rcmdcheck any::remotes ." + extra-packages: "any::rcmdcheck any::remotes" token: ${{ secrets.GITHUB_TOKEN }} - name: Session info @@ -88,6 +91,12 @@ jobs: uses: ./.github/workflows/custom/after-install if: hashFiles('.github/workflows/custom/after-install/action.yml') != '' + - name: Build, install and upload the binary package + id: binary + uses: cynkra/cynkratemplate/.github/actions/build-binary@main + with: + name: ubuntu-26.04-rrelease-base + - name: Update the testthat snapshots uses: cynkra/cynkratemplate/.github/actions/update-snapshots@main if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository @@ -127,9 +136,15 @@ jobs: with: cache-version: rcc-dev-${{ matrix.package }}-1 needs: build, check - extra-packages: "any::rcmdcheck r-lib/remotes@f-618-universe ." + extra-packages: "any::rcmdcheck r-lib/remotes@f-618-universe" token: ${{ secrets.GITHUB_TOKEN }} + # Same runner and R version as the base job, so its binary fits. + - name: Install the package from the base job's binary + uses: cynkra/cynkratemplate/.github/actions/install-binary@main + with: + name: ${{ needs.R-CMD-check-base.outputs.binary }} + - name: Install dev version of ${{ matrix.package }} env: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index e18d2fd..f184652 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -77,6 +77,7 @@ jobs: sha: ${{ steps.commit.outputs.sha }} versions-matrix: ${{ steps.versions-matrix.outputs.matrix }} dep-suggests-matrix: ${{ steps.dep-suggests-matrix.outputs.matrix }} + binary: ${{ steps.binary.outputs.artifact-name }} name: "Smoke test: stock R" @@ -124,11 +125,14 @@ jobs: uses: ./.github/workflows/custom/after-install if: hashFiles('.github/workflows/custom/after-install/action.yml') != '' - # Must come after the custom after-install workflow - - name: Install package - run: | - _R_SHLIB_STRIP_=true R CMD INSTALL . - shell: bash + # Must come after the custom after-install workflow. + # Builds the commit as pushed: the styling and roxygenizing below may + # push a new commit on top, which this binary does not include. + - name: Build, install and upload the binary package + id: binary + uses: cynkra/cynkratemplate/.github/actions/build-binary@main + with: + name: ubuntu-26.04-rrelease-smoke # From here on, every step is marked `continue-on-error: true` so that a # failing check doesn't hide the results of all the checks that follow. @@ -391,6 +395,19 @@ jobs: uses: ./.github/workflows/custom/after-install if: hashFiles('.github/workflows/custom/after-install/action.yml') != '' + # Before the snapshot tests and the check, which reuse its compilation: + # `load_all()` finds the shared object in `src/`, and `R CMD check` hits ccache. + - name: Build, install and upload the binary package + uses: cynkra/cynkratemplate/.github/actions/build-binary@main + # The covr entries run no check and build an instrumented package of their own. + if: ${{ ! matrix.covr }} + with: + # `matrix.os` rather than `runner.os`: ubuntu-24.04 and ubuntu-26.04 + # are both "Linux", and their binaries differ. `matrix.desc` tells + # apart the custom entries that share an OS and an R version, so an + # entry that shares both with another one needs a `desc`. + name: ${{ matrix.os }}-r${{ matrix.r }}${{ matrix.desc && format('-{0}', matrix.desc) || '' }} + - name: Must allow NOTEs if packages are missing, even with _R_CHECK_FORCE_SUGGESTS_ uses: cynkra/cynkratemplate/.github/actions/allow-notes-when-deps-missing@main - name: Update the testthat snapshots @@ -450,9 +467,15 @@ jobs: with: cache-version: rcc-dev-${{ matrix.package }}-1 needs: build, check - extra-packages: "any::rcmdcheck any::remotes ." + extra-packages: "any::rcmdcheck any::remotes" token: ${{ secrets.GITHUB_TOKEN }} + # Same runner and R version as the smoke test, so its binary fits. + - name: Install the package from the smoke test's binary + uses: cynkra/cynkratemplate/.github/actions/install-binary@main + with: + name: ${{ needs.rcc-smoke.outputs.binary }} + - name: Remove ${{ matrix.package }} and all strong dependencies run: | pkg <- "${{ matrix.package }}" From c353cd3366568d0e4c106e4297bfa52fcd63f23f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kirill=20M=C3=BCller?= Date: Sat, 26 Sep 2026 23:10:16 +0200 Subject: [PATCH 06/10] fix(revdep2): Let a slice with no packages check nothing instead of failing (cynkra/cynkratemplate#150) Co-authored-by: Claude --- .github/workflows/revdep2/shard.R | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/revdep2/shard.R b/.github/workflows/revdep2/shard.R index 033b280..291e341 100644 --- a/.github/workflows/revdep2/shard.R +++ b/.github/workflows/revdep2/shard.R @@ -614,8 +614,17 @@ runnable <- names(sources) # last one with nothing but the cheap ones -- and the deadline, which stops the # shard when the next check will not fit, would then bite unevenly. Round robin # gives every slice the same mix. +# +# Not `seq(index, length(runnable), by = of)`: seq() refuses a `from` past +# `to` ("wrong sign in 'by' argument"), so that spelling is an R *error* for a +# shard with fewer runnable packages than slices. A one-package shard, the +# common retry case, checked its package in slice 1 and then crashed slices 2 +# and 3, turning the job red; an empty `runnable` -- every package a depfail -- +# crashed slice 1 before a single manifest line was written. Filtering the +# positions lets such a slice select nothing and check nothing. if (check_slice$of > 1L) { - mine <- seq(check_slice$index, length(runnable), by = check_slice$of) + mine <- seq_along(runnable) + mine <- mine[mine %% check_slice$of == check_slice$index %% check_slice$of] inform(sprintf( "Slice %d/%d: %d of this shard's %d runnable package(s)", check_slice$index, From 86ae31788832dec0b96f10b731c9c613a44660b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kirill=20M=C3=BCller?= Date: Sat, 26 Sep 2026 23:14:46 +0200 Subject: [PATCH 07/10] ci: Bound every job with `timeout-minutes` (cynkra/cynkratemplate#144) Co-authored-by: Claude Opus 5.5 Co-authored-by: Claude --- .github/workflows/R-CMD-check-dev.yaml | 4 ++++ .github/workflows/R-CMD-check-status.yaml | 1 + .github/workflows/R-CMD-check.yaml | 12 ++++++++++++ .github/workflows/commit-suggest.yaml | 1 + .github/workflows/fledge.yaml | 2 ++ .github/workflows/format-suggest.yaml | 1 + .github/workflows/lock.yaml | 1 + .github/workflows/pkgdown.yaml | 1 + .github/workflows/revdep.yaml | 3 +++ .github/workflows/revdep2.yaml | 2 ++ 10 files changed, 28 insertions(+) diff --git a/.github/workflows/R-CMD-check-dev.yaml b/.github/workflows/R-CMD-check-dev.yaml index 03dd190..8c6c94b 100644 --- a/.github/workflows/R-CMD-check-dev.yaml +++ b/.github/workflows/R-CMD-check-dev.yaml @@ -19,6 +19,7 @@ permissions: jobs: matrix: runs-on: ubuntu-26.04 + timeout-minutes: 30 # longest run seen: 2 min outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} @@ -40,6 +41,7 @@ jobs: check-matrix: runs-on: ubuntu-26.04 + timeout-minutes: 15 # longest run seen: 1 min needs: matrix name: Check deps @@ -54,6 +56,7 @@ jobs: R-CMD-check-base: runs-on: ubuntu-26.04 + timeout-minutes: 120 # longest run seen: 46 min name: base @@ -112,6 +115,7 @@ jobs: - R-CMD-check-base runs-on: ubuntu-26.04 + timeout-minutes: 120 # longest run seen: 57 min name: 'rcc-dev: ${{ matrix.package }}' diff --git a/.github/workflows/R-CMD-check-status.yaml b/.github/workflows/R-CMD-check-status.yaml index d9bf26f..3ea5337 100644 --- a/.github/workflows/R-CMD-check-status.yaml +++ b/.github/workflows/R-CMD-check-status.yaml @@ -36,6 +36,7 @@ permissions: {} jobs: rcc-status: runs-on: ubuntu-26.04 + timeout-minutes: 15 # longest run seen: 1 min name: "Update commit status" diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index f184652..ed76a6a 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -62,6 +62,14 @@ name: rcc permissions: contents: read +# Every job in the workflows of this kit sets `timeout-minutes`, +# and notes the longest run seen across the fleet next to it. +# GitHub's default is six hours, so a step that hangs instead of failing +# keeps the `rcc` status pending and holds a runner until someone cancels the run. +# Every job that runs R CMD check or builds the pkgdown site gets the same 120 minutes. +# The other jobs get a limit well above their longest run, because only a hang should reach it. +# The measurements are from September 2026, +# over the last 30 to 100 completed runs of each workflow in nine repositories of the fleet. jobs: rcc-smoke: # Deliberately amd64. The smoke test is the gate that also styles, @@ -72,6 +80,7 @@ jobs: # (rcc-full) instead, where fail-fast: false isolates failures and jobs don't # mutate the repo. Revisit once the arm image is generally available. runs-on: ubuntu-26.04 + timeout-minutes: 120 # longest run seen: 28 min outputs: sha: ${{ steps.commit.outputs.sha }} @@ -314,6 +323,7 @@ jobs: rcc-smoke-check-matrix: runs-on: ubuntu-26.04 + timeout-minutes: 15 # longest run seen: 1 min name: "Check matrix" @@ -343,6 +353,7 @@ jobs: - rcc-smoke runs-on: ${{ matrix.os }} + timeout-minutes: 120 # longest run seen: 99 min if: ${{ needs.rcc-smoke.outputs.versions-matrix != '' }} @@ -443,6 +454,7 @@ jobs: - rcc-smoke runs-on: ubuntu-26.04 + timeout-minutes: 120 # longest run seen: 16 min if: ${{ needs.rcc-smoke.outputs.dep-suggests-matrix != '' && (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.run-rcc-suggests)) }} diff --git a/.github/workflows/commit-suggest.yaml b/.github/workflows/commit-suggest.yaml index db01c1c..1fba383 100644 --- a/.github/workflows/commit-suggest.yaml +++ b/.github/workflows/commit-suggest.yaml @@ -47,6 +47,7 @@ permissions: {} jobs: commit-suggest: runs-on: ubuntu-26.04 + timeout-minutes: 15 # longest run seen: 1 min if: github.event.workflow_run.event == 'pull_request' permissions: diff --git a/.github/workflows/fledge.yaml b/.github/workflows/fledge.yaml index 3e041d9..d2e0656 100644 --- a/.github/workflows/fledge.yaml +++ b/.github/workflows/fledge.yaml @@ -40,6 +40,7 @@ permissions: {} jobs: check_news: runs-on: ubuntu-26.04 + timeout-minutes: 15 # longest run seen: 1 min permissions: contents: read outputs: @@ -56,6 +57,7 @@ jobs: fledge: runs-on: ubuntu-26.04 + timeout-minutes: 60 # longest run seen: 16 min needs: check_news if: needs.check_news.outputs.should_run == 'true' permissions: diff --git a/.github/workflows/format-suggest.yaml b/.github/workflows/format-suggest.yaml index d3f6b70..06ea06b 100644 --- a/.github/workflows/format-suggest.yaml +++ b/.github/workflows/format-suggest.yaml @@ -41,6 +41,7 @@ jobs: format-suggest: name: format-suggest runs-on: ubuntu-26.04 + timeout-minutes: 15 # longest run seen: 1 min # Only run this job if changes come from a fork. # We commit changes directly on the main repository. if: github.event.pull_request.head.repo.full_name != github.repository diff --git a/.github/workflows/lock.yaml b/.github/workflows/lock.yaml index 00c37cc..6745715 100644 --- a/.github/workflows/lock.yaml +++ b/.github/workflows/lock.yaml @@ -11,6 +11,7 @@ on: jobs: lock: runs-on: ubuntu-26.04 + timeout-minutes: 15 # longest run seen: 2 min steps: # Pinned to a commit, not to `@patch-1`: a branch ref is mutable, so # anything landing on that branch would run here with write access to diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml index dcacf8e..35176b3 100644 --- a/.github/workflows/pkgdown.yaml +++ b/.github/workflows/pkgdown.yaml @@ -21,6 +21,7 @@ permissions: {} jobs: pkgdown: runs-on: ubuntu-26.04 + timeout-minutes: 120 # longest run seen: 137 min, in duckdb-r name: "pkgdown" diff --git a/.github/workflows/revdep.yaml b/.github/workflows/revdep.yaml index 71281eb..9c7d08e 100644 --- a/.github/workflows/revdep.yaml +++ b/.github/workflows/revdep.yaml @@ -12,6 +12,7 @@ permissions: jobs: matrix: runs-on: ubuntu-26.04 + timeout-minutes: 30 # longest run seen: 2 min outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} @@ -43,6 +44,7 @@ jobs: check-matrix: runs-on: ubuntu-26.04 + timeout-minutes: 15 # longest run seen: 1 min needs: matrix steps: - uses: actions/checkout@v6 @@ -56,6 +58,7 @@ jobs: needs: matrix runs-on: ubuntu-26.04 + timeout-minutes: 120 # no recent runs to measure name: 'revdep: ${{ matrix.package }}' diff --git a/.github/workflows/revdep2.yaml b/.github/workflows/revdep2.yaml index c77ac3c..ff4f1a2 100644 --- a/.github/workflows/revdep2.yaml +++ b/.github/workflows/revdep2.yaml @@ -363,6 +363,7 @@ jobs: if: inputs.dry-run != true runs-on: ubuntu-26.04 + timeout-minutes: 90 # longest run seen: 9 min name: "Build the dev binary" @@ -755,6 +756,7 @@ jobs: && inputs.dry-run != true runs-on: ubuntu-26.04 + timeout-minutes: 120 # longest run seen: 3 min name: "Collect results and report" From 2d744f34ad8f76766f25268961d288f2f27e8a67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kirill=20M=C3=BCller?= Date: Sat, 26 Sep 2026 23:15:17 +0200 Subject: [PATCH 08/10] feat(ci): Report coverage on pull requests from this repository (cynkra/cynkratemplate#146) Co-authored-by: Claude --- .github/workflows/R-CMD-check.yaml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index ed76a6a..3edda78 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -128,7 +128,8 @@ jobs: cache-version: rcc-smoke-2 needs: build, check, website, roxygen2 # Beware of using dev pkgdown here, has brought in dev dependencies in the past - extra-packages: any::rcmdcheck r-lib/roxygen2 any::decor r-lib/pkgdown deps::. + # covr and xml2 are what the covr leg of rcc-full installs, for the coverage step below. + extra-packages: any::rcmdcheck r-lib/roxygen2 any::decor r-lib/pkgdown r-lib/covr any::xml2 deps::. - name: Run the repository-specific after-install steps uses: ./.github/workflows/custom/after-install @@ -210,6 +211,21 @@ jobs: with: results: ${{ runner.os }}-smoke-test + # A pull request from this repository gets no version matrix (see the step + # that collects it), and so no covr leg either. Coverage is computed here + # instead, from the environment the smoke test already installed, so that + # such a pull request still gets a coverage report. In the smoke test rather + # than a job of its own, because this job's status is the pull request's + # `rcc` status: a separate job would let it turn green, and auto-merge go + # ahead, while coverage was still running. + - name: Compute and upload the test coverage + id: covr + continue-on-error: true + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && !startsWith(github.head_ref, 'cran-') + uses: cynkra/cynkratemplate/.github/actions/covr@main + with: + token: ${{ secrets.CODECOV_TOKEN }} + - name: Build the pkgdown website id: pkgdown-build continue-on-error: true @@ -287,6 +303,7 @@ jobs: Roxygenize the documentation|Roxygenize|${{ steps.roxygenize.outcome }} Commit and push the generated changes|Commit|${{ steps.commit.outcome }} Run R CMD check|R CMD check|${{ steps.check.outcome }} + Compute and upload the test coverage|Coverage|${{ steps.covr.outcome }} Build the pkgdown website|pkgdown build|${{ steps.pkgdown-build.outcome }} Build and deploy the pkgdown website|pkgdown deploy|${{ steps.pkgdown-deploy.outcome }} From cf32c01661ca6aefdf37610dcd49cc3cc0e90231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kirill=20M=C3=BCller?= Date: Sat, 26 Sep 2026 23:18:29 +0200 Subject: [PATCH 09/10] feat(ci): Run R-hub checks on every `cran-*` push, through `rhub-setup` and `rhub-check` actions (cynkra/cynkratemplate#145) Co-authored-by: Claude Opus 5.5 Co-authored-by: Claude --- .github/actions/rhub-check/action.yml | 46 +++++++++++ .github/actions/rhub-setup/action.yml | 48 ++++++++++++ .github/actions/rhub-setup/platforms.R | 71 +++++++++++++++++ .github/workflows/rhub.yaml | 103 +++++++++++++++++++++++++ 4 files changed, 268 insertions(+) create mode 100644 .github/actions/rhub-check/action.yml create mode 100644 .github/actions/rhub-setup/action.yml create mode 100644 .github/actions/rhub-setup/platforms.R create mode 100644 .github/workflows/rhub.yaml diff --git a/.github/actions/rhub-check/action.yml b/.github/actions/rhub-check/action.yml new file mode 100644 index 0000000..3ad7904 --- /dev/null +++ b/.github/actions/rhub-check/action.yml @@ -0,0 +1,46 @@ +name: "Check on one R-hub platform" +description: > + Run R CMD check for one entry of the matrices that `rhub-setup` produces, + in its container or on its virtual machine. + The calling job's matrix must be named `config`: + `r-hub/actions/run-check` reads `matrix.config.label` to decide + which sanitizer, valgrind or rchk findings to look for. + +inputs: + job-config: + description: "The `job-config` of the matrix entry." + required: true + token: + description: "Passed on to the R-hub actions, which use it for runs that `rhub::rhub_check()` starts." + required: false + default: "" + setup-r: + description: "Whether to install R first: `true` on a virtual machine, `false` in a container, which brings its own." + required: false + default: "false" + +runs: + using: "composite" + steps: + - uses: r-hub/actions/checkout@v1 + + - uses: r-hub/actions/setup-r@v1 + if: inputs.setup-r == 'true' + with: + job-config: ${{ inputs.job-config }} + token: ${{ inputs.token }} + + - uses: r-hub/actions/platform-info@v1 + with: + job-config: ${{ inputs.job-config }} + token: ${{ inputs.token }} + + - uses: r-hub/actions/setup-deps@v1 + with: + job-config: ${{ inputs.job-config }} + token: ${{ inputs.token }} + + - uses: r-hub/actions/run-check@v1 + with: + job-config: ${{ inputs.job-config }} + token: ${{ inputs.token }} diff --git a/.github/actions/rhub-setup/action.yml b/.github/actions/rhub-setup/action.yml new file mode 100644 index 0000000..8dd1fc1 --- /dev/null +++ b/.github/actions/rhub-setup/action.yml @@ -0,0 +1,48 @@ +name: "Choose the R-hub platforms" +description: > + Choose the R-hub platforms to check the package on, + and resolve them into the container and virtual-machine matrices that `rhub-check` runs. + +inputs: + config: + description: > + Comma- or space-separated R-hub platforms, overriding the package's own. + Empty uses `Config/gha/rhub-platforms` from DESCRIPTION, + or else the default for the package, see `platforms.R`. + required: false + default: "" + +outputs: + config: + description: "The chosen platforms, comma-separated, empty for none." + value: ${{ steps.choose.outputs.config }} + containers: + description: "Matrix of the container platforms, `[]` for none." + value: ${{ steps.setup.outputs.containers || '[]' }} + platforms: + description: "Matrix of the virtual-machine platforms, `[]` for none." + value: ${{ steps.setup.outputs.platforms || '[]' }} + +runs: + using: "composite" + steps: + - uses: r-lib/actions/setup-r@v2 + with: + use-public-rspm: true + + - name: Choose the platforms + id: choose + env: + REQUESTED: ${{ inputs.config }} + run: | + Rscript "${{ github.action_path }}/platforms.R" + shell: bash + + # Skipped rather than called with an empty list: the outputs above turn a + # skipped step into two empty matrices, without pulling R-hub's image. + - name: Resolve the platforms into matrices + id: setup + if: steps.choose.outputs.config != '' + uses: r-hub/actions/setup@v1 + with: + config: ${{ steps.choose.outputs.config }} diff --git a/.github/actions/rhub-setup/platforms.R b/.github/actions/rhub-setup/platforms.R new file mode 100644 index 0000000..76e2b91 --- /dev/null +++ b/.github/actions/rhub-setup/platforms.R @@ -0,0 +1,71 @@ +# Choose the R-hub platforms to check the package on, and write them to the +# `config` output as a comma-separated list, empty for none. +# +# Run from the package's checkout. The first of these that is set wins: +# +# * `REQUESTED`, the `config` input of the action; +# * `Config/gha/rhub-platforms` in DESCRIPTION, a comma- or space-separated +# list, or `none` to skip the checks; +# * the default: `nosuggests` for every package, plus `gcc-asan`, +# `clang-asan`, `clang-ubsan`, `valgrind` and `rchk` when `src/` holds +# sources to compile. rchk inspects the package's shared object and fails +# on a package without one, so it belongs to compiled code only. + +requested <- trimws(Sys.getenv("REQUESTED")) +# read.dcf(), not `grep`: a DCF value may wrap onto continuation lines. +declared <- read.dcf("DESCRIPTION", fields = "Config/gha/rhub-platforms")[1, 1] + +# The file types R CMD INSTALL compiles from src/. +sources <- dir( + "src", + pattern = "[.](c|cc|cpp|cxx|f|f90|f95|m|mm)$", + ignore.case = TRUE, + recursive = TRUE +) +compiled <- length(sources) > 0 + +if (nzchar(requested)) { + spec <- requested + origin <- "the `config` input of this run" +} else if (!is.na(declared)) { + spec <- declared + origin <- "`Config/gha/rhub-platforms` in DESCRIPTION" +} else if (compiled) { + spec <- "nosuggests gcc-asan clang-asan clang-ubsan valgrind rchk" + origin <- "the default for a package with compiled code in src/" +} else { + spec <- "nosuggests" + origin <- "the default for a package without compiled code" +} + +platforms <- strsplit(trimws(spec), "[[:space:],]+")[[1]] +platforms <- platforms[nzchar(platforms)] + +# The list is spliced into a shell command by r-hub/actions/setup, +# so anything but a plain platform name is refused here. +bad <- grep("^[A-Za-z0-9._-]+$", platforms, value = TRUE, invert = TRUE) +if (length(bad) > 0) { + stop("Not an R-hub platform name: ", paste0("'", bad, "'", collapse = ", "), call. = FALSE) +} +if ("none" %in% platforms) { + if (length(platforms) > 1) { + stop("`none` cannot be combined with other platforms.", call. = FALSE) + } + platforms <- character() +} + +config <- paste(platforms, collapse = ",") +cat("config=", config, "\n", sep = "", file = Sys.getenv("GITHUB_OUTPUT"), append = TRUE) + +summary <- c( + "## R-hub platforms", + "", + if (length(platforms) > 0) { + paste0("Checking on ", paste0("`", platforms, "`", collapse = ", "), ", from ", origin, ".") + } else { + paste0("No checks, as set by ", origin, ".") + }, + "" +) +writeLines(summary) +cat(summary, sep = "\n", file = Sys.getenv("GITHUB_STEP_SUMMARY"), append = TRUE) diff --git a/.github/workflows/rhub.yaml b/.github/workflows/rhub.yaml new file mode 100644 index 0000000..20d6207 --- /dev/null +++ b/.github/workflows/rhub.yaml @@ -0,0 +1,103 @@ +# R-hub checks: the CRAN check flavours the `rcc` matrix does not cover, +# such as the sanitizers, valgrind and rchk, run in R-hub's containers and virtual machines. +# Derived from R-hub's generic workflow, +# https://github.com/r-hub/actions/blob/v1/workflows/rhub.yaml, +# and still what `rhub::rhub_check()` dispatches: it passes the platforms in the `config` input. +# +# A push to a `cran-*` branch checks the release candidate on the package's platforms: +# `Config/gha/rhub-platforms` in DESCRIPTION, or else a default that depends on +# whether the package has compiled code. +# The `rhub-setup` action chooses them, see `platforms.R` beside it. +name: R-hub +run-name: >- + ${{ github.event_name == 'push' && format('Release candidate {0}', github.ref_name) || github.event.inputs.id || format('Manual run by @{0}', github.triggering_actor) }}${{ github.event_name != 'push' && format(': {0}', github.event.inputs.name || github.event.inputs.config || 'the package''s platforms') || '' }} + +on: + push: + branches: + - "cran-*" + workflow_dispatch: + inputs: + config: + description: >- + Comma-separated list of R-hub platforms to use. + Empty uses the package's platforms, see the top of rhub.yaml. + Full list: https://r-hub.github.io/containers/ + type: string + default: "" + name: + description: "Run name. You can leave this empty." + type: string + id: + description: "Unique ID. You can leave this empty." + type: string + +# A push supersedes the previous push to the same branch. +# Dispatched runs, which is how `rhub::rhub_check()` starts one, never cancel each other. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name == 'push' && 'push' || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + setup: + runs-on: ubuntu-26.04 + timeout-minutes: 15 + + outputs: + containers: ${{ steps.setup.outputs.containers }} + platforms: ${{ steps.setup.outputs.platforms }} + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Choose the platforms + id: setup + uses: cynkra/cynkratemplate/.github/actions/rhub-setup@main + with: + config: ${{ github.event.inputs.config }} + + linux-containers: + needs: setup + if: needs.setup.outputs.containers != '' && needs.setup.outputs.containers != '[]' + runs-on: ubuntu-26.04 + # No `timeout-minutes`, unlike the rest of the kit: an instrumented build and check + # is slow by design (valgrind on duckdb-r has taken up to 353 minutes), + # so the check jobs keep GitHub's six-hour default. + name: ${{ matrix.config.label }} + strategy: + fail-fast: false + matrix: + # Named `config`: `rhub-check` relies on `matrix.config.label`. + config: ${{ fromJson(needs.setup.outputs.containers) }} + container: + image: ${{ matrix.config.container }} + + steps: + - uses: cynkra/cynkratemplate/.github/actions/rhub-check@main + with: + job-config: ${{ matrix.config.job-config }} + token: ${{ secrets.RHUB_TOKEN }} + + other-platforms: + needs: setup + if: needs.setup.outputs.platforms != '' && needs.setup.outputs.platforms != '[]' + runs-on: ${{ matrix.config.os }} + # No `timeout-minutes`, like the container checks above. + name: ${{ matrix.config.label }} + strategy: + fail-fast: false + matrix: + # Named `config`: `rhub-check` relies on `matrix.config.label`. + config: ${{ fromJson(needs.setup.outputs.platforms) }} + + steps: + - uses: cynkra/cynkratemplate/.github/actions/rhub-check@main + with: + job-config: ${{ matrix.config.job-config }} + token: ${{ secrets.RHUB_TOKEN }} + setup-r: true From fa872a36c9e670e3c38c7e661b09c2a73af2d101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kirill=20M=C3=BCller?= Date: Sun, 27 Sep 2026 01:45:33 +0200 Subject: [PATCH 10/10] refactor(ci): Serve the revdep scripts with the actions instead of copying them (cynkra/cynkratemplate#149) --- .github/workflows/revdep2.yaml | 52 +- .github/workflows/revdep2/README.md | 1487 ---------------- .github/workflows/revdep2/build.R | 84 - .github/workflows/revdep2/check-pair.sh | 125 -- .github/workflows/revdep2/collect.R | 834 --------- .github/workflows/revdep2/fetch.sh | 44 - .github/workflows/revdep2/load-test.sh | 101 -- .github/workflows/revdep2/plan.R | 1418 --------------- .github/workflows/revdep2/preflight.R | 502 ------ .github/workflows/revdep2/shard.R | 1403 --------------- .github/workflows/revdep2/util.R | 1676 ------------------ .github/workflows/revdep2/watch-resources.sh | 109 -- .github/workflows/revdep4.yaml | 66 +- .github/workflows/revdep4/README.md | 200 --- .github/workflows/revdep4/compare-one.R | 311 ---- .github/workflows/revdep4/queue.sh | 508 ------ .github/workflows/revdepx/README.md | 285 --- .github/workflows/revdepx/base-image.sh | 159 -- .github/workflows/revdepx/build.R | 144 -- .github/workflows/revdepx/check-half.sh | 309 ---- .github/workflows/revdepx/collect.R | 974 ---------- .github/workflows/revdepx/compare.R | 552 ------ .github/workflows/revdepx/fetch.sh | 52 - .github/workflows/revdepx/image.R | 754 -------- .github/workflows/revdepx/load-test.sh | 101 -- .github/workflows/revdepx/plan.R | 1435 --------------- .github/workflows/revdepx/shard-prep.sh | 227 --- .github/workflows/revdepx/shard.R | 1027 ----------- .github/workflows/revdepx/util.R | 1614 ----------------- .github/workflows/revdepx/watch-resources.sh | 109 -- 30 files changed, 79 insertions(+), 16583 deletions(-) delete mode 100644 .github/workflows/revdep2/README.md delete mode 100644 .github/workflows/revdep2/build.R delete mode 100755 .github/workflows/revdep2/check-pair.sh delete mode 100644 .github/workflows/revdep2/collect.R delete mode 100755 .github/workflows/revdep2/fetch.sh delete mode 100755 .github/workflows/revdep2/load-test.sh delete mode 100644 .github/workflows/revdep2/plan.R delete mode 100644 .github/workflows/revdep2/preflight.R delete mode 100644 .github/workflows/revdep2/shard.R delete mode 100644 .github/workflows/revdep2/util.R delete mode 100755 .github/workflows/revdep2/watch-resources.sh delete mode 100644 .github/workflows/revdep4/README.md delete mode 100644 .github/workflows/revdep4/compare-one.R delete mode 100755 .github/workflows/revdep4/queue.sh delete mode 100644 .github/workflows/revdepx/README.md delete mode 100755 .github/workflows/revdepx/base-image.sh delete mode 100644 .github/workflows/revdepx/build.R delete mode 100755 .github/workflows/revdepx/check-half.sh delete mode 100644 .github/workflows/revdepx/collect.R delete mode 100644 .github/workflows/revdepx/compare.R delete mode 100755 .github/workflows/revdepx/fetch.sh delete mode 100644 .github/workflows/revdepx/image.R delete mode 100755 .github/workflows/revdepx/load-test.sh delete mode 100644 .github/workflows/revdepx/plan.R delete mode 100755 .github/workflows/revdepx/shard-prep.sh delete mode 100644 .github/workflows/revdepx/shard.R delete mode 100644 .github/workflows/revdepx/util.R delete mode 100755 .github/workflows/revdepx/watch-resources.sh diff --git a/.github/workflows/revdep2.yaml b/.github/workflows/revdep2.yaml index ff4f1a2..2745346 100644 --- a/.github/workflows/revdep2.yaml +++ b/.github/workflows/revdep2.yaml @@ -9,8 +9,9 @@ # shards: the heaviest round-robin first, everything else placed where it # shares the most dependencies. The shard count follows the parallel capacity, # because only max-parallel shards ever run at once and every shard past that -# pays another setup without starting any earlier (see revdep2/README.md for -# the algorithm and its trade-offs). +# pays another setup without starting any earlier (see +# https://github.com/cynkra/cynkratemplate/blob/main/.github/actions/revdep2/README.md +# for the algorithm and its trade-offs). # # The build job compiles the dev version once into the binary artifact every # shard installs; it needs only the checkout, so it runs in parallel with @@ -40,7 +41,8 @@ # Results land in artifacts: # revdep2-report the merged report -- fetch with # `gh run download --name revdep2-report` -# or ./.github/workflows/revdep2/fetch.sh +# or .github/actions/revdep2/fetch.sh , +# run from a checkout of cynkra/cynkratemplate # revdep2-baseline old-version results, reused by later runs when the # revdep's version, our CRAN version, the R series and the # resolved dependency versions all still match (and the @@ -58,7 +60,7 @@ # # Dispatch-only, deliberately: nothing here runs on push. `ref` checks any # branch, tag or commit SHA (the dispatch itself can only target a branch or -# tag, and the tree must contain these scripts), `depth` widens the net to +# tag; the scripts come with the actions, not from that tree), `depth` widens the net to # transitive reverse dependencies, and `dry-run` stops after planning. Check # results never turn the run red -- the summary and the report artifact are # the deliverable; only infrastructure failures fail jobs. @@ -67,7 +69,7 @@ on: workflow_dispatch: inputs: ref: - description: "Branch, tag, or commit SHA to check (the tree must contain the revdep2 scripts); default: the dispatched ref" + description: "Branch, tag, or commit SHA to check; default: the dispatched ref" type: string default: "" packages: @@ -224,6 +226,10 @@ jobs: actions: read steps: + # The scripts are served with the kit's actions, not copied into the repository. + - name: Locate the revdep scripts + uses: cynkra/cynkratemplate/.github/actions/revdep-scripts@main + - name: Check out the ref under test uses: actions/checkout@v6 with: @@ -252,7 +258,7 @@ jobs: GH_TOKEN: ${{ github.token }} OUT: ${{ runner.temp }}/plan.json run: | - Rscript ./.github/workflows/revdep2/plan.R + Rscript "${REVDEP2_DIR}/plan.R" shell: bash # Before anything can fail: the shards are planned, and the plan is @@ -317,8 +323,8 @@ jobs: - name: Report what the install consumed if: always() && steps.plan.outputs.shards != '0' && inputs.dry-run != true run: | - ./.github/workflows/revdep2/watch-resources.sh once "after the job" - ./.github/workflows/revdep2/watch-resources.sh oom + "${REVDEP2_DIR}/watch-resources.sh" once "after the job" + "${REVDEP2_DIR}/watch-resources.sh" oom du -sh ~/.cache/R/pkgcache "${RUNNER_TEMP}/lib" 2>/dev/null || true shell: bash @@ -371,6 +377,10 @@ jobs: contents: read steps: + # The scripts are served with the kit's actions, not copied into the repository. + - name: Locate the revdep scripts + uses: cynkra/cynkratemplate/.github/actions/revdep-scripts@main + - name: Check out the ref under test uses: actions/checkout@v6 with: @@ -387,7 +397,7 @@ jobs: env: OUT_DIR: ${{ runner.temp }}/pkg run: | - Rscript ./.github/workflows/revdep2/build.R + Rscript "${REVDEP2_DIR}/build.R" shell: bash - name: Upload the package binary @@ -502,6 +512,10 @@ jobs: PKG_SYSREQS: true steps: + # The scripts are served with the kit's actions, not copied into the repository. + - name: Locate the revdep scripts + uses: cynkra/cynkratemplate/.github/actions/revdep-scripts@main + - name: Check out the ref under test uses: actions/checkout@v6 with: @@ -619,7 +633,7 @@ jobs: # upload. - name: Start the resource sampler run: | - watch=./.github/workflows/revdep2/watch-resources.sh + watch="${REVDEP2_DIR}/watch-resources.sh" "${watch}" once "shard ${SHARD} before the install" RESOURCE_LOG="${RUNNER_TEMP}/results/resources.log" \ "${watch}" watch 30 "shard ${SHARD}" & @@ -630,7 +644,7 @@ jobs: env: PHASE: install run: | - Rscript ./.github/workflows/revdep2/shard.R + Rscript "${REVDEP2_DIR}/shard.R" shell: bash # The checks run in three slices, each followed by an upload. @@ -667,7 +681,7 @@ jobs: PHASE: check CHECK_SLICE: 1/3 run: | - Rscript ./.github/workflows/revdep2/shard.R + Rscript "${REVDEP2_DIR}/shard.R" shell: bash # Named per attempt: a re-run of one shard must not overwrite the results @@ -691,7 +705,7 @@ jobs: PHASE: check CHECK_SLICE: 2/3 run: | - Rscript ./.github/workflows/revdep2/shard.R + Rscript "${REVDEP2_DIR}/shard.R" shell: bash - name: Upload the shard results (2 of 3) @@ -710,7 +724,7 @@ jobs: PHASE: check CHECK_SLICE: 3/3 run: | - Rscript ./.github/workflows/revdep2/shard.R + Rscript "${REVDEP2_DIR}/shard.R" shell: bash # Before the final upload, so the last sample and the OOM verdict ride in @@ -720,8 +734,8 @@ jobs: - name: Report what the shard consumed if: always() run: | - ./.github/workflows/revdep2/watch-resources.sh once "shard ${SHARD} after the checks" - ./.github/workflows/revdep2/watch-resources.sh oom + "${REVDEP2_DIR}/watch-resources.sh" once "shard ${SHARD} after the checks" + "${REVDEP2_DIR}/watch-resources.sh" oom df -BG / "${RUNNER_TEMP}" 2>/dev/null || true shell: bash @@ -767,6 +781,10 @@ jobs: actions: read steps: + # The scripts are served with the kit's actions, not copied into the repository. + - name: Locate the revdep scripts + uses: cynkra/cynkratemplate/.github/actions/revdep-scripts@main + - name: Check out the ref under test uses: actions/checkout@v6 with: @@ -832,7 +850,7 @@ jobs: BASELINE_OUT: ${{ runner.temp }}/baseline TIMINGS_OUT: ${{ runner.temp }}/timings run: | - Rscript ./.github/workflows/revdep2/collect.R + Rscript "${REVDEP2_DIR}/collect.R" shell: bash - name: Upload the report diff --git a/.github/workflows/revdep2/README.md b/.github/workflows/revdep2/README.md deleted file mode 100644 index b04f5d8..0000000 --- a/.github/workflows/revdep2/README.md +++ /dev/null @@ -1,1487 +0,0 @@ -# `revdep2` — sharded reverse-dependency checking - -`.github/workflows/revdep2.yaml` checks every CRAN reverse dependency of the -package twice — once against the CRAN version, once against the checked-out -dev version — and reports the difference, -the way [revdepcheck](https://github.com/r-lib/revdepcheck) does, -but spread over as many GitHub Actions jobs as can actually run at once. -The trade is deliberate: -runner minutes are spent (duplicate setup, duplicate dependency installs) -to buy wall clock — -but only while a free lane makes that a trade at all, -which is why the shard count follows `max-parallel` rather than the budget. -The older `revdep.yaml` spends one job per package, -and `revdepcheck::revdep_check()` spends one machine for everything. - -## Topology - -``` -plan (1 job, ~30 min) build (1 job, parallel to plan) - ├─ enumerate revdeps to `depth`, └─ R CMD build - │ or take the retry/explicit list + R CMD INSTALL --build - ├─ weigh each by what its check cost → revdep2-pkg artifact - │ here last time, else by CRAN's - │ time scaled to this machine - ├─ walk earlier runs youngest first: - │ the baseline donor, the prebuilt - │ libraries, the measured timings - ├─ decide per package what is reusable - ├─ partition into as many shards as - │ one wave can run, in whole waves - │ → plan.json (artifact) + matrix (job output) - └─ then, in the same job, the preflight - (skipped by a dry run; continue-on-error, - so it cannot take the matrix with it) - ├─ unpack the prebuilt packages the plan found - ├─ install + load every dependency more than one shard needs - └─ pack the library for the next run - → depfail.json, revdep2-lib(-index), - warm pak cache (saved under the plan hash) - -test (one job per shard, max-parallel throttled, fail-fast: false) - ├─ "Install packages" step (PHASE=install) - │ ├─ unpack this run's preflight library, then the plan's donors - │ ├─ install the shard's dependency union (pak, sysreqs on, warm cache) - │ ├─ install the system requirements of what was unpacked, not installed - │ └─ build two one-package libraries: CRAN release, dev binary - └─ "Check the shard" step (PHASE=check) - ├─ per package, both checks at once against those cascading libraries - └─ results + manifest.ndjson → revdep2-results-- - -collect (1 job, if: always() past plan/build/test) - ├─ merge all shard attempts (+ carried results of a retried run) - ├─ reports via revdepcheck: README.md, cran.md, problems{,.md}/, failures{,.md}/ - ├─ pool what every check and every shard cost, job durations included - └─ manifest.json, job summary, revdep2-report + revdep2-baseline - + revdep2-timings artifacts -``` - -The workflow is dispatch-only — nothing runs on push — -and `dry-run: true` stops after planning, -which is how a plan is inspected for free. -The `ref` input checks any branch, tag or commit SHA: -the dispatch itself can only target a branch or tag, -so arbitrary SHAs travel through the input, -with the one constraint that the tree must contain these scripts. - -Planning and the preflight share a job. They were two, and the second did -nothing the first had not already paid for: a runner, a checkout, `setup-r`, a -pak install — and then downloaded the plan artifact the first had just -uploaded, to read it back. That is about two minutes of a three-hour run, -which is not really the point; the point is that planning is twenty seconds of -work wearing a whole job's overhead, and it sits on the critical path, because -the preflight cannot start until it ends and every shard waits on the -preflight. - -Merging them costs one thing, and it has to be bought back explicitly. -A preflight failure used to be survivable -because the plan's outputs — the shard matrix among them — -were already safe in a job that had succeeded. -In one job a failing preflight step would take the matrix with it, -and the run would have nothing left to check. -So the plan's outputs are set and its artifact uploaded -*before* the preflight step runs, -and that step is `continue-on-error`: -it shows as failed, the summary says what it could not install, -and the shards go ahead and install those packages themselves. -Which is what the preflight has always been — an optimization, never a gate. - -The shard's two steps are one driver called twice, `PHASE=install` and -`PHASE=check` (`PHASE=all` runs both in one process, which is what a local -invocation wants). -Splitting them is a reporting change and nothing else: -the install is minutes to an hour, the checks are hours, -and as one step the run page could only report their sum — -so "shard 14 took five hours" said nothing about -whether it spent them unpacking dependencies or checking packages, -and the install times turn out to vary a lot between shards. -The phases share the job environment and the work directory; -the install leaves the libraries and an `install-state.json` -of what it cost behind, and the check phase picks both up. -Nothing is done twice. - -A failing check never fails anything: -`fail-fast: false` isolates shard-level accidents, -the shard driver records per-package failure as data, -the collector runs on `always()` past its prerequisites, -and check results never turn the run red — -the job summary and the `revdep2-report` artifact are the deliverable. -A red job means broken infrastructure, not a broken revdep. - -## Weighing and partitioning - -Enumeration is breadth-first to `depth`: -level 1 depends on the package directly, -level 2 on a level-1 package, and so on, -up to the fixpoint for `depth: all`. -Deeper levels break through their intermediaries, -so their CRAN-vs-dev comparison stays meaningful, -and their install closures pull the intermediaries in automatically. - -A package's weight is what its two checks are expected to cost *here*. -The best answer is what they cost here last time — -the timings artifact below carries it per package. -Where no run has checked the package yet, -CRAN's own number stands in: -`tools::CRAN_check_results()` publishes per-package check times per flavor as -`T_total`, -the planner takes the `r-release-linux-x86_64` flavor, -and scales it by the ratio the last runs measured between CRAN's machine and -this one -(0.47 in the first calibrated run: these runners check faster than CRAN -reports). -Packages CRAN has no timing for either get the cohort median. -Every package is checked twice, but the two run at once, -so a package weighs one pair of checks plus a small fixed overhead. - -That used to be conditional — -a package *without* a reusable baseline weighed double, -one with a baseline weighed single, because the baseline stood in for its old -check. Now that both halves always run, the condition is gone. -So is the doubling, and that part is easy to get backwards: -the two halves run *concurrently*, -so a package costs the shard the wall clock of the slower one, -not the sum of both. -`check_scale` is fitted from exactly that quantity — -`t_old` and `t_new` are both recorded as the pair's wall clock, -and the calibration fits `median(seconds / T_total)` from them — -so the weight already *is* the pair. -Multiplying by two would price every shard at twice its wall clock, -which buys twice the shards, each paying its own setup, -and defers packages at the deadline that would have fit. - -The per-check timeout stays on CRAN's number and is not calibrated: -`max(REVDEP2_TIMEOUT_MIN_MINUTES, REVDEP2_TIMEOUT_FACTOR × T_total)`. -A timeout is a safety net for a check that has gone wrong, -so it should be generous where the estimate is merely typical — -and against the local estimate that same factor would be a third as forgiving. -The floor matters more than the factor: -19 of 770 packages in run 31048405399 were killed by a 10-minute one, -all of them compile-heavy (Stan models, mostly) and cheap by CRAN's numbers, -13 with the floor as their entire budget. -It is 20 minutes now, which covers every one of them. - -### The shard count is bounded by the parallel capacity - -Only `max-parallel` shards run at once (default 20), -so shards come in waves of that size. -That default is not arbitrary and raising it is not free: -GitHub caps how many jobs an account may run concurrently -(20 on the free plan, more on paid ones), -and 20 leaves room for the rest of the repository's CI. -Past that ceiling GitHub queues jobs whatever `max-parallel` says — -so a plan told it has more lanes than the account really has -does not get them, it just cuts more shards, -each paying its own setup while it waits for one. -**`max-parallel` should be the concurrency that actually exists, never more.** - -Waves are what makes the shard count a real decision: -a shard after a full wave does not start any earlier for existing. -It waits for a lane, and arrives having paid another setup -(runner image, R, pandoc, TinyTeX, artifact downloads — -charged at 6 minutes until a run measures it) -plus its own dependency install. -Splitting past one wave therefore buys nothing and costs per shard, -which is what a 45-minute budget did to the 771-revdep batch: -111 shards, six waves, 6 h 16 min end to end, -for 2233 minutes of checking that one wave of 20 shards holds comfortably. - -So the count is decided in two steps: - -* **while one wave is enough, the budget decides.** - `ceil(check minutes / shard-budget-minutes)`, capped at `max-parallel`. - Below a full wave every extra shard really does start immediately, - so cutting finer buys wall clock, and a small batch stays cheap. -* **past that, the capacity decides, in whole waves.** - `shard-capacity-minutes` (default 80% of the shard's own deadline, - so 240 min) is the most check time one shard may be given - before its deadline starts deferring packages. - The plan takes `ceil(check minutes / capacity)` shards, - rounded up to a whole number of waves, - and never more than the 250-leg matrix limit. - -The result is `max-parallel` shards for anything that fits in one wave, -`2 × max-parallel` for twice that, and so on — -the capacity is filled, and nothing is split for the sake of splitting. - -Both steps count *check* minutes, -but a shard also pays its setup and its installs inside the same deadline, -and how much that is only a real partition can say -(a shard's install union is not a per-package constant). -So the greedy pass below runs, the heaviest shard's **full** estimate is -compared against `REVDEP2_DEADLINE_MINUTES`, -and a shard count that cannot hold it grows by another whole wave -and partitions again. -The 20% between `shard-capacity-minutes` and the deadline is the room -this check normally finds sufficient; -the re-partition is what happens when it is not. - -### When even the matrix is not enough - -A matrix holds at most 256 legs, so `max-shards` caps at 250, -and 250 shards × 240 check minutes is the ceiling of one run: -about 60 000 check minutes. -Past that the plan **refuses to start**, with the numbers that make the case -and the ways out — rather than dispatching a run -that spends hours to report half its packages as `deferred`. - -That ceiling is about the matrix, not about patience. -At 20 lanes, 250 shards is thirteen waves; -a batch anywhere near the limit is a multi-day run long before it is -an impossible one, and the wave count in the plan summary is what says so. - -The way out it recommends is `part`: - -```sh -gh workflow run revdep2.yaml -f part=1/3 -gh workflow run revdep2.yaml -f part=2/3 -gh workflow run revdep2.yaml -f part=3/3 -``` - -Each part is an ordinary, independent run with its own report. -The cut is made on the weight-ordered package list, dealt round robin, -so the parts are of similar size and no coordination is needed: -every part re-derives the same order from the same CRAN metadata, -and a part that is still too big refuses in turn and names a bigger `G`. -Later parts start warmer than the first, -because baselines and prebuilt libraries are shared through the usual -artifacts. -The plan prints the `G` it needs, so the number is never guessed. - -**What a split does not buy is wall clock.** -Run back to back or at the same time, the parts draw on the same account -concurrency, so the total time is what it always was — -plus one more preflight per part. -What it buys is a run that fits: shards inside their deadline, -a report per part rather than one that lands hours late and half `deferred`, -and a retry granularity that is a part rather than the whole set. -That is also why the refusal is the only place `part` is recommended: -a batch that fits should stay one run. - -For scale, the largest set anyone here runs — `tibble`'s, -planned at the default 20 lanes: - -* 2398 strong revdeps, 14 035 check minutes on CRAN's numbers: - 60 shards in three waves, ~13 h; - 40 shards in two waves, ~7 h, once the 0.47 measured scale applies. -* 3032 with `which: most` (Suggests included), 18 515 check minutes: - 80 shards in four waves, ~17 h; - 40 shards in two waves, ~9 h, calibrated. - -Both are well inside the refusal, which is about the matrix limit — -but neither is short, and shard *count* is not what makes them long. -Three things bound such a run before the shard count does: - -* **the lanes.** Wall clock is roughly total work ÷ concurrency, - and concurrency is the account's ceiling - (20 concurrent jobs on a free plan, more on paid ones). - Nothing in this plan changes that number: - splitting into more shards, or into `part` runs, only adds setups. -* **the preflight**, which installs the shared part of the dependency - universe in one job before any shard starts, and is not parallel at all. - Keeping it to what more than one shard needs is why it is the shared part - and not all 4406 packages. -* **the heaviest single package**, which is never split across shards. - `duckdb` alone is a 171-minute leg of the `most` plan on CRAN's numbers; - no shard count gets under it, and the refusal names such a package - rather than recommending a split that cannot help. - -The capacity-bound plans above also sit close to their deadline by design -(the `most` one is planned at ~91% of it): -filling the capacity is what keeps the wave count down, -and a shard that overruns anyway defers the rest for `retry-run` -rather than losing it. -`shard-capacity-minutes` is the dial to lower if that trade reads wrong. - -Assignment is greedy, in two phases: - -1. **Round-robin the heavyweights.** - The `K` heaviest packages are dealt one per shard, - so no two giants end up queued behind each other. -2. **Marginal-cost placement for the rest.** - Every remaining package, heaviest first, - goes to the shard where - `load + weight + install_seconds × |dependencies the shard does not yet have|` - is smallest. - The install penalty (default 2.5 s per package, from a warm binary cache) - is what pulls packages with overlapping dependency trees together, - so a shard's install phase is amortised over packages that share it. - -### Calibration: the plan learns from the last runs - -Three constants drive everything above, -and all three used to be guesses: -how fast a check runs here, what a shard costs before it checks anything, -and what one more dependency costs to install. -Guesses compound in the wrong direction — -an overestimate of the check load asks for more shards than the capacity can -run, -and every one of those shards costs a setup it never earns back. - -So every run now measures itself. -Each shard records what its phases cost -(`timing.json`: unpack, install, checks, and its own wall time), -the collector adds what the shards' *jobs* took — -read off the API, because the minutes before the driver starts -are invisible from inside it and are precisely the price of one more shard — -and publishes the lot as `revdep2-timings`, -a small artifact next to the report the way `revdep2-lib-index` -sits next to the library. - -The next plan takes the youngest few of those -(`REVDEP2_MEASURED_MAX_RUNS`, default 3, within -`REVDEP2_MEASURED_MAX_AGE_DAYS`, default 60, same platform), -and reduces them to medians: - -| Constant | Measured as | Fallback | -| --- | --- | --- | -| check scale | check seconds here ÷ `T_total` | 1 | -| setup per shard | job minutes − driver minutes | 6 min | -| install per dependency | install minutes ÷ packages installed | 2.5 s | - -Per-package measurements win over the scaled CRAN number wherever a run has -one; -the scale only prices the packages nobody has checked here yet. -Pooling several runs rather than trusting the newest -keeps a small retry run — which measures a handful of packages — -from redefining the constants on its own. -`REVDEP2_CHECK_SCALE`, `REVDEP2_SETUP_MINUTES` and `REVDEP2_INSTALL_SECONDS` -override the measurement where a human knows better, -and a fresh repository with no measured run at all -simply plans on CRAN's numbers and the fallbacks, -which is what the workflow did before. - -### Why greedy, not an exact optimisation - -The exact problem is makespan minimisation with sequence-dependent setup -costs — bin packing crossed with a coverage objective — -which is NP-hard in both halves, -and the classic greedy (LPT: longest processing time first) -is already within 4/3 − 1/(3K) of the optimal makespan. -The inputs do not deserve better: -CRAN timings come from a different machine under different load, -install costs are a scalar guess, -and the actual runtime moves with cache hits and CRAN's own state. -An ILP or local-search pass could shave minutes off the plan on paper -and would still be wrong by more than that in practice — -and it would need a solver in a job whose entire budget is two minutes. -The greedy pass is O(n · K) with a bitmap per shard, -runs in well under a second for thousands of revdeps, -and its plans are inspectable -(the plan job's summary prints per-shard estimates and contents). - -`each.yaml` in duckdb-r solves the mirror-image problem -(contiguous slices of a commit history, reuse via ccache adjacency); -its two-pass rebalancing exists because contiguity pins its cuts. -Here nothing is contiguous — any package can sit anywhere — -so the whole plan family collapses into the one greedy pass -and the only dial left is the budget. - -## The CRAN baseline, and when it is reused - -The old-version check of a revdep does not involve the dev code at all: -it is the CRAN version of this package, the revdep, and their dependencies. -Its result therefore outlives the run that produced it, -and re-checking it every run would double the bill for no information. - -The collector publishes every old-version result as `revdep2-baseline` -(`baseline.json` plus one `old.rds` per package), -and the planner reuses an entry only when *everything that shaped it* -is unchanged: - -| Criterion | Compared | -| --- | --- | -| revdep version | baseline vs `available.packages()` now | -| our CRAN version | baseline vs CRAN now | -| R series | baseline vs the runner's `major.minor` | -| dependency versions | md5 over the sorted `package version` lines of the revdep's whole install closure, from CRAN metadata | -| age | `checked_at` within `baseline-max-age-days` (default 30) | - -The dependency fingerprint is the load-bearing one: -a tidyverse point release changes the environment an old check ran in, -and versions-of-us-and-them alone would happily reuse a result -that release just invalidated. -The age cap backstops what CRAN metadata cannot see — -the runner image, system libraries, network state. -Reuse does not refresh `checked_at`: -a result ages from the day it actually ran. -`refresh-baseline: true` ignores all of it for one run. - -Baselines are looked up newest-run-first across the workflow's history -(any branch — the dev code plays no part in an old check), -in the same single walk that picks the prebuilt libraries below, -and a retried run's own report doubles as its donor. -A missing, expired, or partially unusable baseline is never an error; -the affected packages are simply checked fresh. - -## Prebuilt packages, and which runs they come from - -Installing the dependency universe is the other half of the bill, -and without care it is paid many times over: -every shard installs its own union, -so on a runner with no binaries to install from, -one package is compiled once in each of the shards that needs it, -every run, for a result identical each time. - -So the preflight installs it once centrally and publishes what it installed. -Its library is packed into `revdep2-lib` -(one uncompressed `library.tar` — `upload-artifact` zips what it uploads, -and deflating gigabytes twice buys nothing), -next to `revdep2-lib-index`, -a small `lib.json` naming the R series, the platform, -and every package version in it. -The index is a separate artifact on purpose: -a later plan reads it to decide what a run is good for -without downloading the library it describes. - -That artifact is reused twice, and the first one needs no history at all: -**every shard unpacks its own run's preflight library** — -`preflight` is a `needs` of `test`, so it is simply downloaded — -and only then falls back to earlier runs -for whatever the preflight could not supply. -This is the half that pays on the very first run: -the compile happens once in the preflight -instead of once more in each shard. - -### What the preflight installs, and what it leaves alone - -Not the whole universe — only what more than one shard needs. - -The saving from preflighting a package -is exactly the number of shards that need it, minus one: -build it once centrally instead of once per shard. -For a package only one shard needs, that saving is zero. -It is built once either way; -all the preflight does is move that build -off a shard, where it runs twenty-wide, -and onto the critical path, where it runs alone. - -On the 3434-revdep set that is not a small tail — -**1633 of 4406 packages, 37% of the preflight's work, for no saving at all.** -Leaving them to their shard is free in the strict sense: - -- `REVDEP2_PREFLIGHT_MIN_SHARDS: 1` — preflight 4406, **4406 installs across the run** -- `REVDEP2_PREFLIGHT_MIN_SHARDS: 2` — preflight 2767, **4406 installs across the run** -- `REVDEP2_PREFLIGHT_MIN_SHARDS: 3` — preflight 2108, **5065 installs across the run** - -Two is the default because it is the last threshold that costs nothing: -the total number of installs is identical, -and so is the number of downloads, -since a package one shard needs is fetched once whoever fetches it. -Three sheds another 659 packages from the preflight -but has each of them built twice instead of once — -a real trade, worth having as a knob -for a preflight under time pressure, -not worth defaulting to. -The proportions barely move with the shard count: -at 120 shards instead of 60 the same threshold keeps 2773 rather than 2767. - -The threshold is capped at the shard count. -With a single shard every package is needed by every shard, -so the cap is what stops a small run -from publishing an empty library to the next one. - -What this costs is early warning. -The preflight load-tests what it installs, -so a dependency only one shard needs -is no longer proven before the checks start — -it fails in that shard instead, as a `depfail`, -which is a result the report already knows how to carry. - -It is a `needs`, but not a prerequisite. -`test` runs on `!cancelled()` past a failed preflight -and `collect` does not consult its result at all, -because the preflight buys two things — -a free rebuild for the shards, and dependency failures diagnosed early — -and neither is worth the run. -A shard installs its own union regardless, -and that union is a fraction of the universe: -in the run that made this necessary, -a median of 478 packages against 4397. -Losing the preflight makes a run slower and blinder, not void. - -For the rest, the plan walks the workflow's completed runs, youngest first, -and takes libraries until it has covered -every package this run will install, -or has run out of runs: - -* a run only donates while its library is younger than - `REVDEP2_PREBUILT_MAX_AGE_DAYS` (default 14) - and its index reports the same R series and platform — - binaries are only portable that far; -* each donor is credited only with the packages - no younger donor already had, - so a run that adds nothing is never downloaded; -* at most `REVDEP2_PREBUILT_MAX_RUNS` runs donate (default 5, `0` turns - reuse off) — every extra donor is another full library download. - -What it settles lands in `plan.json` as `prebuilt.runs`, -and the preflight and every shard unpack from exactly that list — -the shards after their own run's library, for the gaps it leaves. -Every unpack takes only the packages that job needs, -and never overwrites what is already in the library -or loaded in the session -(pak and everything the job installed for itself stays untouched). - -**pak still runs over the whole set afterwards.** -Unpacking is not installing: -CRAN moves between runs, and a package whose version changed -has to be built after all. -The install runs with `upgrade = TRUE` whenever anything was restored, -because the plan's dependency fingerprints — the ones that decide -baseline reuse — are computed from CRAN *now*, -and `upgrade = FALSE` would quietly freeze the donor's versions instead. -Reuse therefore skips *building* what has not changed; -it never skips resolving it. - -The one failure mode reuse introduces is a stale binary: -a runner image moves under a package that was compiled against the old one. -The preflight load-tests everything it installs anyway, -so it catches exactly that — a restored package that will not load -is thrown away, rebuilt from source, and load-tested again -before it counts as a dependency failure. - -## Results, artifacts, tooling - -Every artifact this workflow writes: - -| Artifact | Content | Lifetime | -| --- | --- | --- | -| `revdep2-plan` | `plan.json` | 30 days | -| `revdep2-pkg` | source tarball, platform binary, `meta.json` | 30 days | -| `revdep2-preflight` | `depfail.json`, `resources.log` | 30 days | -| `revdep2-lib` | `library.tar` (the preflight's installed library), `lib.json` | 14 days | -| `revdep2-lib-index` | `lib.json`: R series, platform, package versions | 30 days | -| `revdep2-results--` | `manifest.ndjson`, `pkgs/

/{old,new}.rds`, kept check output | 30 days | -| `revdep2-report` | `README.md`, `cran.md`, `manifest.json`, `problems.md` + `problems/`, `failures.md` + `failures/`, all `pkgs/` | 90 days | -| `revdep2-baseline` | `baseline.json`, `old-rds/

.rds` | 90 days | -| `revdep2-timings` | `timings.json`: check seconds per package, cost per shard | 90 days | - -The reports are revdepcheck's own, -generated through its `results` injection point -(`cloud_report_summary()` and friends), -so `README.md` reads exactly like a local `revdep_check()`'s. - -The job summary embeds that report, -with two changes that a summary needs and a file does not. -Its links are rewritten: -a summary is served from the run's own URL, -where revdepcheck's `problems.md#pkg` resolves to `/actions/runs/problems.md` -and 404s, -so package names point at CRAN instead -and the report files are named where they actually live — -the `revdep2-report` artifact of the run. -And its "Failed to check" section is replaced -by a **Could not be checked** table -listing, per package, the version, the shard, -the check counts of whichever phase ran, and *why* — -the timeout and its duration, the dependencies that would not install, -whether installation failed under the dev version only or under both. -revdepcheck cannot say any of that -because the shim it is fed for an uncomparable package carries no detail; -the manifest has it all. - -The summary closes with **What this run cost** — -check speed against CRAN's numbers, shard job durations, -setup and install cost — -because those are the numbers the next plan is sized in, -and they are worth reading next to the results they came from. - -To fetch a run's results: - -```sh -.github/workflows/revdep2/fetch.sh # newest completed run -.github/workflows/revdep2/fetch.sh # a specific one -``` - -It brings `timings.json` down with the report, -so a plan can be replayed against exactly what that run measured: - -```sh -REVDEP2_MEASURED_DIR=revdep OUT=plan.json \ - Rscript .github/workflows/revdep2/plan.R -``` - -To re-check only what a run could not declare ok — -after fixing the code, after a flaky failure, after a deadline deferral: - -```sh -gh workflow run revdep2.yaml -f retry-run= -``` - -The retry's collector carries the donor run's untouched results over, -so its report is complete again, not a fragment. - -## The report is the repository's record - -`revdep/` in the checkout is where the results live between runs. -`revdepcheck::cloud_check()` wrote `README.md`, `problems.md`, -`failures.md` and `cran.md` there long before this workflow existed, -`revdep/run-broken.R` read them back to re-check what was broken, -and the analysis next to them — `problems-analysis.md`, `examples/`, -the notification scripts — is what a human adds on top. -So the collector writes the same four files, in the same format -(they come out of revdepcheck itself), plus `manifest.json`, -and commits them back to the ref that was checked. - -`problems.md` and `failures.md` are *assembled* rather than written whole. -Each package with a section has its own file — -`revdep/problems/.md`, `revdep/failures/.md` — -and the standalone file is the concatenation of them, -in case-insensitive name order: - -```sh -cat $(ls revdep/problems/*.md | LC_COLLATE=C sort -f) > revdep/problems.md -``` - -Case-insensitive because that is the order -revdepcheck's own single-call version produced, -so the committed report keeps the order it already had; -sorting the raw names instead moves `ECoL`, `GoodFitSBM`, `MetaNet` -and `R6causal` to the front and rewrites the whole file for nothing. -`method = "radix"` on a lowercased key rather than plain `sort()`, -because `sort()` collates in the runner's locale — -the committed order should not depend on where the collector ran. - -Two things fall out of the split. -A diff names the package that changed -instead of a line range in a file thousands of lines long. -And a run only has to touch the packages it actually checked — -a retry of 27 rewrites 27 files -and leaves the other 984 exactly as the repository has them. -That last part is the point: -a run that learnt nothing about a package -no longer gets to rewrite that package's section. -The rule is one predicate in `collect.R` — -a result that is `carried`, `missing` or `deferred`, -or one that is `ok` only because the package errors under *both* versions, -keeps whatever the repository already has, -and everything else is written from this run's comparison -or deleted when there is nothing left to report. -The `file.exists` half of it makes that a preference rather than a rule: -with no section on disk there is nothing to protect, -so it is written like any other. - -The `ok`-but-broken clause is worth spelling out. -`ok` means there is no *new* problem, which is what this workflow is for — -it does not mean the package works. -79 of run 31930350338's 984 `ok` results error under both versions: -55 never got as far as a check -(their dependencies would not install, -so both halves stopped at `checking package dependencies` -within a couple of seconds and agreed), -and 24 are real checks of genuinely broken packages. -A run that reproduces breakage on both sides -has not shown that a section someone wrote for that package is stale, -so it does not delete it. -This only declines to delete; nothing is added. -`problems.md` is the newly-broken list by design, -and widening it to "still broken" is revdepcheck's `all = TRUE` -and a different report. - -Only those paths are staged — -the five files and the two directories. -`git add

` stages removals too, -which is what retires a package the run found fixed. -The analysis and the examples beside them are human-authored, -and `pkgs/` — the raw check output, gigabytes of it — -belongs in the `revdep2-report` artifact and nowhere near a commit. - -A ref that cannot receive a commit simply does not get one: -a tag, a SHA, a fork's branch. -That is a fact about the dispatch rather than a failure, -so the step says so with a `::notice::` and stops; -the report is in the artifact either way. -The same applies to a protected branch, a read-only token, or a push -that races with someone else's — the step is `continue-on-error`, -because a report that cannot be committed is still a report. -Set the repository variable `REVDEP2_COMMIT_REPORT` to `false` -to turn the commit off entirely. - -### Re-checking what was broken - -`packages: broken` takes the packages to check from that committed report: -`manifest.json` when this workflow wrote it (every result that is not `ok`), -and otherwise revdepcheck's own markdown — -the `# ()` headings of `problems.md` and `failures.md`, -plus the "Failed to check" table in `README.md`. -That is `revdep/run-broken.R`'s loop, as a dispatch input. - -It is the cheap run: 39 packages rather than 771 for the report as it stands, -one wave, and every one of them a package that was wrong last time. -`retry-run: ` is the sibling for a run that did not finish — -the report is about *results*, a retry is about *coverage*. - -## Failure modes - -| Situation | Outcome | -| --- | --- | -| A revdep breaks under the dev version | `newly_broken` in manifest and report; the run stays green | -| The checked ref is a tag or a SHA | the report is not committed; a `::notice::` says so and the artifact still has it | -| The report cannot be pushed (protection, fork, race) | the step is `continue-on-error`; the run keeps its result | -| A revdep fails the same way under both versions | `ok` (no *new* problems), visible in the report's tables | -| A revdep fails to *install* under both versions | `failed` — there is nothing to compare | -| A check times out | `timeout` kills it at `max(floor, factor × its CRAN time)`; reported `timeout`, not `failed`, with the check step it died at | -| A revdep's strong dependencies cannot install | `depfail`, check not attempted, named in the shard summary | -| A revdep needs a package that is not on CRAN | both halves stop at `checking package dependencies ... ERROR` in seconds and agree, so the pair compares clean; reported `depmissing`, not `ok`, with the missing packages named | -| A dependency fails the preflight | reported in the preflight summary and `depfail.json`; shards still try their own subset | -| A pak install chunk fails | the chunk is reported and the rest still run; what is still missing is retried one package at a time | -| A pak install chunk never returns | killed at `REVDEP2_INSTALL_TIMEOUT_MINUTES`, tree and all; the next chunk starts a fresh pak | -| The installs cannot finish inside the job | no chunk is started past `REVDEP2_INSTALL_DEADLINE_MINUTES`; what did install is still load-tested, packed and published | -| A dependency's `loadNamespace()` hangs | the batch times out and is retried one package at a time, so the culprit is named as a load failure | -| pak's metadata database is empty or unreadable | detected by probe before the first install, cleared and rebuilt once; the preflight stops if that does not fix it | -| `/tmp` fills and R can no longer start | `TMPDIR` is on the big disk, so it does not; the sampler still reports `/tmp` if it ever does | -| A restored package's system library is absent | `sysreqs_check_installed()` names it and `sysreqs_fix_installed()` installs it, in both the preflight and every shard | -| A *checked* package needs a system library | the revdep is never in the library -- `R CMD check` builds it from its tarball -- so the shard resolves the members' own `SystemRequirements` with `pkg_sysreqs()` and installs what is missing before the first check | -| The preflight job itself dies | the shards run anyway and install their own unions, the collector still reports; only the free rebuild and the early diagnosis are lost | -| A shard hits its deadline | remaining packages `deferred`; finished old-halves still uploaded and baseline-fed | -| The runner stops answering the service | "The hosted runner lost communication with the server" names CPU, memory and network starvation as its causes, and a dead runner takes its `if: always()` steps with it — so both the preflight and the shards stream a resource sample every 30 s while they work, and the checks run under `nice -n 10` (and `ionice -c3` where it exists) so the agent is never the process that loses | -| A shard job dies hard | the checks run in three slices, each followed by an upload, so at most a third is lost and the rest is already in the artifact; packages a later slice never reached are `deferred`, anything with no line at all the collector reconciles against the plan as `missing`, naming the shard, and `retry-run` re-checks exactly those | -| Every shard dies | the report is still written, with every package `missing`; the artifact download is tolerated, not required | -| The batch is too big for 250 shards | the plan refuses before anything starts, and names the `part` split that fits | -| A shard is re-run | new artifact per attempt; the collector lets the later attempt win per package | -| The run is planned into a single shard | `download-artifact` unpacks a lone match into the download path itself rather than a per-artifact subdirectory, so the collector looks for a `manifest.ndjson` in both places | -| The baseline artifact is gone | planner reuses nothing, everything checked fresh | -| No run has published timings yet | the plan uses CRAN's times unscaled and the fallback constants, and errs towards more shards | -| The collector cannot read the job durations | setup stays at its default; check and install costs are still measured | -| No earlier run has a usable library | shards still unpack this run's preflight library; only the preflight itself installs from scratch | -| The preflight could not pack a library | the download step is skipped, shards fall back to the plan's donors and pak | -| A donor's library artifact expires between plan and shard | that shard installs those packages itself; the run is unaffected | -| A restored binary will not load | the preflight rebuilds it from source and re-tests; only a second failure is a `depfail` | -| CRAN bumps a dependency mid-run | shards install what resolves at their start; the recorded fingerprint is the plan's — next run re-fingerprints | -| The package is not on CRAN | plan emits zero shards, run ends green | -| `collect` finds new problems | reported in the summary and the report artifact; the run stays green | - -### When a job is killed rather than failed - -A job that *fails* leaves a diagnosis: -the step reports its error, -the `if: always()` steps run, -and the artifacts are uploaded. -A job that is *killed* leaves almost nothing. -`The runner has received a shutdown signal` and `exit code 143` -is the whole of it — -no post-step runs, -nothing is uploaded, -and the only record that survives -is whatever had already been streamed to the log. - -The preflight is where that happens, -because it is the one job that takes on the entire dependency universe at once, -so three things are arranged to be *live* rather than after the fact: - -- `watch-resources.sh` samples memory, swap, disk, load - and the three largest processes every 30 seconds - while the install runs, - so a kill has a curve leading up to it - instead of a blank. -- The R script runs under `stdbuf -oL`. - R block-buffers stdout when it is not a terminal - and flushes it on exit, - which a killed process never reaches — - that is why pak's progress used to vanish - while the `message()` calls around it, on unbuffered stderr, came through. -- Afterwards, when there is an afterwards, - the kernel's own OOM log is read, - which separates "this job asked for too much memory" - from "the host went away". - -The install itself is also cut down to a size pak handles predictably. -One `pak::pkg_install()` call for a few thousand refs -resolves all of them before it installs any of them, -and that resolution is the part that stops degrading gracefully: -the run above spent ten minutes in it -without a single install starting. -So both the preflight and the shards install in chunks of -`REVDEP2_INSTALL_CHUNK` packages (400), -ordered so that every strong dependency inside the set -is installed before the package that needs it — -each chunk then resolves against a library where its dependencies already are. -The ordering is on strong dependencies only: -Suggests are in the set because a revdep's *check* needs them, -not its installation, -and they are what would make the graph cyclic. -Packages a cycle or a gap in the index leaves unordered go last, together. - -That also changes what a failure costs. -Whatever earlier chunks installed is on disk -and is skipped on the next attempt, -so a chunk that dies costs a chunk rather than the job — -and the log names which one, -where a single opaque call could only go quiet. - -### Nothing waits for ever - -A hang is not a crash, and the difference matters: -a job that fails is over in a minute and says why, -a job that hangs holds a runner -until someone notices and cancels it. -Run 31276552027 spent 76 minutes that way — -`pak::pkg_install()` on chunk 21 of 45 never returned, -at one busy core and flat memory, -after chunks 14 and 20 had already failed -with "error in pak subprocess". -There was no loop to break; -the call simply did not come back, -and nothing in these scripts had a clock. - -Now everything that calls out has one: - -- **each pak install** runs in a `callr` child - and is killed at `REVDEP2_INSTALL_TIMEOUT_MINUTES` (20). - It is killed with `kill_tree()`, not `kill()`, - because what wedges is pak's *own* subprocess — - a grandchild, which outlives a plain kill of its parent. - The child inherits stdout and stderr, - so pak's progress still streams to the job log - with nobody draining a pipe. -- **each load-test batch** runs under a `processx` timeout - (`REVDEP2_LOAD_TIMEOUT_MINUTES`, 10). - `loadNamespace()` is not a thing that necessarily returns: - a package whose `.onLoad` waits on a lock, a port or a display - hangs the child for good. -- **the installs as a whole** stop at - `REVDEP2_INSTALL_DEADLINE_MINUTES` (210), - below the job's own 300. - That is a different question from the per-call limit: - one bounds a single call, the other stops 45 of them - from adding up past what the job has — - and what it cuts off is named, - while the packages that did install - are still load-tested, packed and published. - -Running each install in its own child buys one more thing. -A wedged pak subprocess used to poison every call after it, -which is the likeliest reason chunk 21 hung -where 14 and 20 had merely failed. -Each chunk now starts a fresh R and a fresh pak, -so a bad one is contained to itself. - -### pak's repositories are pinned, and its metadata is checked - -pak reads `getOption("repos")` and adds the Bioconductor repositories -the moment something needs them — -and its metadata database is keyed on that set. -In run 31282820357 the first Bioconductor package -landed in chunk 11 of 45. -The repository set went from 1 to 6, -the metadata database from 7 files to 9, -and the rebuilt database came back **empty**: -`Updated metadata database: 0 B in 9 files`, -parsed in 20 ms where a good one takes 9 seconds. -From chunk 12 on, pak could not find a single package on CRAN — -not vctrs, all 4406 of them. - -Installing in chunks is what made that reachable. -One long-lived pak process holds the parsed database in memory; -45 short-lived ones each re-read it from disk, -so a set that changes under one of them poisons every one that follows. - -Three things follow from that: - -- **The repository set is resolved once and pinned.** - `repo_get(bioc = TRUE)` up front, before the first install, - and the resulting set is handed to every pak child — - an option set in the parent is not inherited, - and a child that resolves its own set is a child that can change it. -- **The database is assessed before it is trusted**, with `meta_list()` - rather than by watching installs fail: - pak is asked how many of a few packages that must exist it can see - (`REVDEP2_METADATA_PROBE`, default `vctrs,cli,R6`). - The probe runs in a fresh process, - because pak keeps the parsed database in the memory of its own subprocess — - which is precisely why the break only surfaced at the *next* chunk. -- **A broken one is cleared exactly once.** - `meta_clean(force = TRUE)` then `meta_update()`. - The clean is the part that matters: - `meta_update()` alone is what produced "0 B in 9 files", - re-validating the broken files and leaving the empty database in place. - Once per job is deliberate — - a database still empty after a clean rebuild is not a stale cache, - and clearing it in a loop would spend the job hiding that. - -The preflight checks before its first install and stops if it cannot be fixed, -rather than failing package by package for hours -and then publishing a cache that fails every shard the same way. -Each shard checks after restoring that cache, -which is the difference between one bad job and sixty. -A chunk that fails is retried once, -but only when the rebuild actually changed something — -against a healthy database, a failed chunk is a real failure. - -### Loading is tested one package at a time, in parallel - -Loading a namespace loads everything it imports, transitively, -so loading the packages *nothing else in the set depends on* -covers the whole set: -in a DAG every other package is reachable from one of those roots -by following dependents upwards. -Measured on the real universe: -**865 roots out of 2645 packages, and nothing left uncovered.** - -The saving in sessions is not the point. -One session per package means one clock per package. -A package whose `.onLoad` blocks used to spend a batch's whole ten minutes -and take 39 innocent packages with it, -and the batch then had to be re-run package by package -to find out which one it was. -And independent sessions run at once, -which is what the runner's other three cores are for -(`load-test.sh`, GNU `parallel` where it exists and `xargs -P` where it does -not, `timeout` per package). - -It is more total work: the roots' closures sum to about 48,000 namespace loads -against roughly 27,000 for 67 batches of 40, -because each root reloads what it shares with the others. -Against that, the batched sweep ran serially, -so four at a time should still roughly halve it. -That last part is a projection, not a measurement — -the next run's preflight timing is what settles it. -`REVDEP2_LOAD_JOBS` and `REVDEP2_LOAD_SWEEP_MINUTES` are the knobs; -the failures are re-run singly afterwards to keep their output, -and there are few of them by construction. - -Every tested package gets a line, with what it cost, -in a collapsed `::group::` sorted slowest first. -That is ~500 lines the default view never shows, -against a step that otherwise says nothing at all -between "load-testing 498 packages" and the summary — -and it is the only place a package that loads *slowly* appears, -though every check of anything downstream of it pays that cost again. -The slowest few are repeated outside the group, where they are read. - -### The two halves get a network port each - -`parallel` picks its default PSOCK port once, when its namespace loads: - -```r -ran1 <- sample.int(.Machine$integer.max - 1L, 1L) / .Machine$integer.max -port <- 11000 + 1000 * ((ran1 + unclass(Sys.time())/300) %% 1) -``` - -The random term is only random while the RNG stream is. -Anything that calls `set.seed()` before `parallel` first loads — -which examples, vignettes and testthat do constantly, for reproducibility — -makes it deterministic, and both halves draw the same number. -Measured: three sessions seeded with 42 gave 11181, 11183, 11183, -against 11005, 11214, 11652 unseeded. - -The time term cannot separate them either. -It sweeps 1000 ports over 300 seconds — 3.3 ports per second — -so two halves that load `parallel` -within a third of a second of each other -land on the same integer port. -They start together and run the same script, so they do. -And the choice is made once per *session*, not per cluster, -so from then on every cluster either half opens races for that one port. - -In run 31893156685 that cost `cia` (port 11477) and `TDApplied` (11058), -both reported `newly_broken` with nothing wrong with them. -Staggering the halves is not a fix: -the separation would have to hold at the moment each loads `parallel`, -the two drift apart by minutes over a check, -and at 300 seconds the sweep wraps back onto itself. -`R_PARALLEL_PORT`, 20000 for old and 30000 for new, -costs nothing that was not already the case — -R fixes one port per session and reuses it regardless — -and both are far from R's own 11000–12000 band. - -### A shard that cannot install still reports - -An install that overruns used to be given the shard's whole deadline, -on the reasoning that an install running into it -leaves no time to check anything. -That is true, and it is the wrong conclusion. -Shard 3 of run 31893156685 sat in its install step for 2 h 33 m, -was cancelled, and its 50 packages came back `missing` — -the one result that tells nobody anything. - -Three things now stand between an install and that outcome: - -- the install gets `REVDEP2_SHARD_INSTALL_MINUTES` of the shard's time, - not all of it, and what it could not install becomes a depfail, - which is a *result*; -- the check step runs on `!cancelled()` rather than `success()`, - so a *failed* install still gets its packages accounted for; -- a check phase that finds no install state writes a manifest saying so - for every package in the shard, rather than exiting and leaving them - to be reported as `missing`. - -**45 minutes**, and that number is measured rather than picked. -The last two runs recorded 39 shard installs: -median 9.4 minutes, p90 13.7, worst 16.6. -So the budget is 2.7× the worst install anyone has actually seen -and 15% of the shard's deadline — -loose enough that a healthy shard can never notice it, -tight enough that shard 3's 2 h 33 m -would have been cut off more than three times sooner. - -It doubles where little was restored. -Every one of those 39 installs had its preflight library — -95% of the union or better, in both runs — -so a *cold* install is unmeasured, -and a preflight that dies is survivable by design -(more so now that it is a `continue-on-error` step), -so cold shards will happen. -The one thing worse than a slow install -is depfailing 50 packages that would have installed -given a few more minutes. - -The per-package fallback in the install is also no longer -`requireNamespace()` in a loop. -That *loads* each package — hundreds of namespaces and their DLLs -into the driver process, and past `R_MAX_NUM_DLLS` (614) -it starts returning `FALSE` for packages that are installed, -so the loop reinstalls them — -and its deadline check only skipped the install, -after the namespace had been loaded. -Which packages are missing is a question about the filesystem, -and `missing_from()` answers it that way. - -### System requirements of packages nobody installed - -`PKG_SYSREQS` is on in both jobs, so pak runs `apt-get` -for the packages *it* installs — -independently on each runner, nothing shared between them. -That covers everything pak builds. - -It does not cover what was unpacked rather than installed. -A shard untars this run's preflight library and the plan's donors -before pak sees anything — -170 of one shard's 436 dependencies in run 31282820357 — -and pak never resolves system requirements for a package -it was not asked to install. -It usually survives, because something else pulls the same apt package in -or the runner image already carries it. -When it does not, a restored binary cannot load its shared library, -and a shard has no load test to catch that: -it surfaces as a check failure blamed on the revdep. - -So the library is asked directly rather than the install list. -`sysreqs_check_installed(library =)` names what is missing -and which packages want it — printed either way, so the gap is visible -even when there is nothing to do — and `sysreqs_fix_installed()` installs it. -Reading the library rather than a recorded apt diff -is what makes this cover donor libraries from earlier runs as well: -their apt state was never recorded anywhere, -but pak can still read what they left behind. - -The preflight does this before its load test, -because a restored package whose system library is absent -fails to load for a reason that has nothing to do with the package — -without it, the package is judged stale, rebuilt from source, -and fails again the same way. -A shard does it after its installs and before its first check. - -### Temporary files go on the big disk - -`/tmp` on this runner image is its own filesystem of about 8 GB — -half the RAM, so a tmpfs — -while the disk `runner.temp` lives on has over 100 GB free. -Everything R does temporarily lands in `/tmp` by default: -source builds, unpacked tarballs, -and callr's own startup files. - -Run 31303054725 filled it after eleven chunks. -What that looks like is not "no space left on device" anywhere useful: - -``` -08:19:33 /tmp 8G free ← job starts -08:30:34 /tmp 2G free -08:31:12 Error in saveRDS(client_env, file = env_file, …) : - error writing to connection -08:31:13 chunk 13 failed: ! callr subprocess failed: could not start R -``` - -Once callr cannot write its startup environment, -no R process starts at all, -and every chunk after that fails in about a second — -which reads like the metadata database being empty, -and is a completely different problem. -So `TMPDIR` points at `runner.temp` in both jobs, -and the sampler keeps `/tmp` in its list -whether or not anything is still using it. - -### Old and new run at the same time - -A shard used to make two passes: every old check, then `R CMD INSTALL` of the -dev binary over the CRAN one, then every new check. -The install in the middle is what forced them apart — -one library can only hold one version of the package under test. - -Two libraries can. -`R_LIBS` is a search path, -so each check names a library holding *exactly one* package — -the CRAN release for old, the dev build for new — -in front of the shared library holding every dependency. -Nothing is installed or uninstalled between them, -so the pair runs concurrently: -`check-pair.sh` starts both `R CMD check` invocations, waits, and records -each one's log and exit status. - -That is worth two things. -A package's wall clock halves, on a four-core runner -where one check keeps about one core busy. -And a package whose old check hangs still gets its new answer, -where before the old timeout meant the run learnt nothing about it at all — -the half that finished is saved, with its own check output, -even though there is no verdict to draw from one side. - -The timeout is coreutils' `timeout` rather than rcmdcheck's, -which makes the distinction reliable: -exit 124 is the deadline, anything else is the check saying something. -`rcmdcheck::parse_check()` then turns each `00check.log` -into the same object `rcmdcheck()` used to return, -so the counts, `compare_checks()` and the manifest are unchanged. - -For a package that is not ok, what is kept is the **difference** between the -two logs (`00check.diff`) next to the new one, -and the same diff is printed into the job log -under a foldable `::group::` heading. -The logs are thousands of lines that are identical in both, -and the handful that are not is the entire point — -so the run page can carry them for every package that is not ok -without anyone downloading an artifact to find out -that a NOTE gained a line. -`REVDEP2_DIFF_MAX_LINES` bounds what is printed (200 by default); -the whole diff is always in the artifact. - -### What the halves differ in that is not the package - -A diff is only worth printing if two identical results produce an empty one, -and two concurrent checks do not naturally produce identical logs. -Two things differ for reasons that have nothing to do with the package: - -- **The paths.** The libraries cascade, so they differ by construction — - `.../lib-old/...` against `.../lib-new/...` — and so do the two check - directories, which the log names in its first line - and quotes in every "see … for details". -- **The stage timings.** `--as-cran` used to set `_R_CHECK_TIMINGS_` to 10, - so every stage slower than that printed its own `[user/elapsed]` pair, - and two checks racing each other for the same four cores - never agree on those. - They are off now — `_R_CHECK_TIMINGS_=""` for the stamps, - `_R_CHECK_EXAMPLE_TIMING_THRESHOLD_=99999` for the - "Examples with CPU … > 5s" table, which is the same noise in table form. - `neutral_log()` still strips them, because a reused baseline - or an older artifact may carry them, - but nothing produces them any more, so the diff a human reads - is only what changed. - Nothing is lost: what a stage cost is still recorded, per line - and for *every* stage rather than only the slow ones, - by the elapsed stamping in `check-pair.sh` — - which is on the driver log, not on the file the halves are compared through. - -Both are removed before the log is parsed *and* before it is diffed. -Measured, not assumed: rphylopic checked against the *same* igraph -on both sides differed in exactly two lines — -the log directory, and `[14s/12s]` against `[13s/11s]` — -and in none once neutralised. - -This is hygiene rather than a fix for anything observed: -`compare_checks()` hashes only the *first line* of each issue, -so noise further down could never have mattered to it. -What it buys is the diff: an empty one now means -the dev version changed nothing about this check. -A package called `newly_broken` whose two logs are identical -is this harness getting it wrong, and the job log says so in as many words. - -### Why a reused baseline made packages look newly broken - -Run 31879790285's shard 9 reported `rphylopic`, `HospitalNetwork` and `orthGS` -as `newly_broken` with the same `1E 0W 0N` in both halves. -All three had a **reused baseline** standing in for the old half. -All eight packages in that shard that ran a fresh old check were `ok`. - -The two halves were parsed by two different parsers. -A baseline from an earlier run was produced by `rcmdcheck::rcmdcheck()`, -which parses the *stream* as `R CMD check` writes it; -this run's half is `parse_check()` on the finished `00check.log`, -where R has gone back and appended the status to the line it opened. -The same failing test therefore renders two ways: - -``` -checking tests ... | checking tests ... ERROR - Running 'testthat.R' | Running 'testthat.R' - ERROR | Running the tests in ... failed. -``` - -`compare_checks()` hashes the first line, so those are two different issues — -`81f6423…` against `23e57fe…` — and the new one matches nothing in the old. -Their `00check.diff` is eight lines, all of it the log directory: -the *logs* agree, and only the objects disagree. - -Run 31879790285 finished with the whole set and the split is stark: - -- of the **909** packages whose old half came from a reused baseline, - **76** were called `newly_broken` — 8.4%; -- of the **78** that ran a fresh old check, **2** were — 2.6%, - and both are real - (`cranly` on `eigen_centrality(scale = FALSE)` now being a - `deprecate_stop()`, and `vkR`). - -29 of the 76 have *identical* counts in both halves, -which is the parser artefact exactly; -the other 47 differ, which is a baseline being a result -from another machine and another CRAN snapshot. -Both are the same mistake: comparing against something -that is not this run. - -This is why both halves always run now. -With the pair concurrent the old check costs no wall clock, -so substituting a baseline bought nothing and cost comparability — -and a baseline is a result from another run anyway: -another machine, another CRAN snapshot, another dependency tree. -It is still read as a second opinion, and when it disagrees -with the old check just run, the shard says so -and records `baseline_agrees` in the manifest. - -### What the summary shows for a package that broke - -Three blocks, each with its own budget rather than sharing one: - -- the **check log**, which says *what* broke and at which stage; -- **`00install.out`**, where a package that could not be installed explains - itself. The check log only points at the file, - which used to mean downloading the artifact to read a compiler error; -- the **`.Rout.fail` transcript**, which is the whole test run. - -and — where the failure was in the examples — -the **`-Ex.Rout` transcript**, which is the same for them. - -`_R_CHECK_TESTS_NLINES_=300` also widens the check log's own copy -of a failed test from R's default 13 lines — -thirteen routinely cuts off the failure itself, -which is both what a reader wants -and the part the old/new diff has to see to be worth printing. -Not unlimited, because that text is carried three times over -(the check log, the diff, the summary) -and one chatty test would otherwise bury the rest of the shard in all three. -`REVDEP2_DETAIL_MAX_LINES` (300) bounds the transcript blocks. - -Bounding it costs nothing, because the complete transcript is kept beside it. -R writes `.Rout.fail` for a test file that failed -and `-Ex.Rout` for the examples, -each the whole thing whatever `_R_CHECK_TESTS_NLINES_` says — -measured: 521 lines at 13, at 300 and at 0 alike. -Both are copied into the shard artifact. -The `-Ex.Rout` one is not a `.fail` file -and so used to be dropped, -which left an examples failure with nothing but the check log's excerpt — -and examples is where most of the interesting failures are. - -What is *not* kept: anything at all for a package that came out `ok` -(its check directory is deleted, deliberately — -909 of them in run 31879790285), -and the old half's own `00check.log`, -which survives only as its half of `00check.diff`. - -Every line that reports a package carries its position in the shard -and an estimate for what is left: - -``` -protti: ok (old 0E 0W 0N, new 0E 0W 0N, 1072s for the pair, 1/51, ~5.0 h left) -``` - -A shard runs for hours and its log is read while it runs, -so "is this nearly done?" should not need counting lines, -and the question actually being asked is *when*. - -The plan already priced every package; -what it could not know is how this runner would compare to its model. -So the remaining packages are priced in the plan's own units -and rescaled by how its estimates have held up in this shard so far — -which absorbs both a slow runner -and a systematically optimistic model, -without either having to be known in advance. -Before the first pair finishes there is nothing to rescale by -and the plan's number stands, which is why the estimate can move a long way -on the first package and very little after that. - -### Spelling is not checked - -It cannot say anything about igraph: -a misspelling in a revdep's DESCRIPTION is the same misspelling in both halves, -so it cancels out of every comparison the workflow makes, -and what is left is noise in a log read to find real differences. -`_R_CHECK_CRAN_INCOMING_: false` already suppresses it — -R's spelling stage lives inside `check_CRAN_incoming()` — -and `_R_CHECK_CRAN_INCOMING_USE_ASPELL_: false` says so out loud -so that `--as-cran` cannot turn it back on. - -A package's *own* `tests/spelling.R` is a different thing. -Those run because `r-lib/actions/setup-r` sets `NOT_CRAN=true`, -which is what makes `skip_on_cran()` not skip. -The shards now set `NOT_CRAN=false` instead, -so they behave like CRAN's own check machines: -the point of this workflow is to find what a released igraph would break, -and a test CRAN never runs cannot break on CRAN. -That silences the spelling tests -and every other `skip_on_cran()` test with them — -a real reduction in what is exercised, -which is why it is an input rather than a constant. -Dispatch with `not-cran: true` to widen the net again. - -`NOT_CRAN` is written in a step rather than in the job's `env` -because `setup-r` writes its own value into `$GITHUB_ENV`, -and a later write is what reliably overrides an earlier one. - -### `\donttest` examples are not run - -`--as-cran` turns on `--run-donttest`. -That is the most expensive thing a check does -and the least useful thing for this workflow: -`\donttest{}` is where packages put the examples too slow to run on CRAN, -so it is where the runners spend their hours -and where the timeouts land — -varPro's old half was killed at 1200 s -in `checking examples with --run-donttest`. -`_R_CHECK_DONTTEST_EXAMPLES_=false` turns it back off. -`\dontrun{}` is off unless asked for, and stays off. -What is left is every example a package expects to run, -which is the part a change to igraph can break. - -### A timeout is not a failure - -Run 31304411628 put 60 packages into `failures.md` marked "timed out", -and shard 7's log shows no sign of trouble — two of them, both stuck at -`Running 'testthat.R'` after every other check step passed in seconds. -Both things are true. -CRAN checks those 60 in a median of 275 s -and these runners measure about half CRAN's time, -so a 20-minute kill is not slowness; it is a hang. - -The reporting was the wrong part. -A check killed by the clock says nothing about the package, -and in the *old* half it says nothing about our change either — -the dev version is not even on that library path. -Such a package is now `timeout` rather than `failed`, -with its own row in the summary and the check step it died at in the message. -`needs_recheck()` treats it as not-ok either way, -so `retry-run` still picks it up. - -The same principle applies to everything these scripts swallow. -Fetching an artifact is an optimization, -so its failure never stops a run — -which is exactly why it has to say *which* failure it was. -An artifact that has really expired, -a `gh` that could not download it, -a truncated zip, -an `unzip` that refused it, -and a tar that ran out of disk -each name themselves now; -before, all of them printed -"no longer has a library artifact", -including the cases where the artifact was demonstrably still there. - -Run ids are strings everywhere in these scripts, -never integers, and `"0"` is the "no such run" sentinel -that `plan.json` and the plan job's outputs use. -GitHub's run ids passed `.Machine$integer.max` in 2026, -so `as.integer("31048405399")` is a silent `NA` -that only surfaces as `missing value where TRUE/FALSE needed` -at the next `if`. -`run_id_chr()` and `has_run()` in `util.R` are how they are handled. - -## Knobs - -| Knob | Input | Variable | Default | -| --- | --- | --- | --- | -| Ref to check (branch, tag, SHA) | `ref` | — | the dispatched ref | -| Packages to check, or `broken` for the committed report's | `packages` | `REVDEP2_PACKAGES` | all revdeps | -| Where that report lives | — | `REVDEP2_REPORT_DIR` | `revdep` | -| Commit the report back to the checked branch | — | `REVDEP2_COMMIT_REPORT` | on | -| Revdep set | `which` | — | `strong` | -| Revdep depth (`1`, `2`, …, `all`) | `depth` | — | 1 | -| Retry a run | `retry-run` | — | — | -| One G-th of the revdeps, for a set too big for one run | `part` (`i/G`) | `REVDEP2_PART` | — | -| Plan only | `dry-run` | — | false | -| Run the tests CRAN skips (`skip_on_cran()`, spelling tests) | `not-cran` | `REVDEP2_NOT_CRAN` | false | -| Check-time target per shard, up to one wave | `shard-budget-minutes` | `REVDEP2_SHARD_BUDGET_MINUTES` | 45 | -| Concurrent shards, and so the wave size — set it to the concurrency the account really has, never more | `max-parallel` | `REVDEP2_MAX_PARALLEL` | 20 | -| Check minutes one shard may hold, which forces further waves | — | `REVDEP2_SHARD_CAPACITY_MINUTES` | 80% of the deadline | -| Ignore reusable baselines | `refresh-baseline` | — | false | -| Oldest reusable baseline | `baseline-max-age-days` | `REVDEP2_BASELINE_MAX_AGE_DAYS` | 30 days | -| Runs donating prebuilt packages | — | `REVDEP2_PREBUILT_MAX_RUNS` | 5 (`0` disables) | -| Oldest reusable prebuilt library | — | `REVDEP2_PREBUILT_MAX_AGE_DAYS` | 14 days | -| Runs the history walk looks at | — | `REVDEP2_HISTORY_RUNS` | 40 | -| Shards a package must be needed by before the preflight installs it | — | `REVDEP2_PREFLIGHT_MIN_SHARDS` | 2 (`1` is the whole universe) | -| Packages per `pak::pkg_install()` call | — | `REVDEP2_INSTALL_CHUNK` | 400 | -| Time limit on one `pak::pkg_install()` call | — | `REVDEP2_INSTALL_TIMEOUT_MINUTES` | 20 | -| Wall clock past which no further install is started | — | `REVDEP2_INSTALL_DEADLINE_MINUTES` | 210 | -| Time limit on one load-test batch | — | `REVDEP2_LOAD_TIMEOUT_MINUTES` | 10 | -| Packages probed to tell a usable metadata database from an empty one | — | `REVDEP2_METADATA_PROBE` | `vctrs,cli,R6` | -| Time limit on probing or rebuilding that database | — | `REVDEP2_METADATA_TIMEOUT_MINUTES` | 10 | -| Time limit on surveying or installing system requirements | — | `REVDEP2_SYSREQS_TIMEOUT_MINUTES` | 20 | -| Wall clock past which the plan warns (never refuses) | — | `REVDEP2_LONG_RUN_HOURS` | 12 h | -| Runs whose timings calibrate the cost model | — | `REVDEP2_MEASURED_MAX_RUNS` | 3 (`0` disables) | -| Oldest measurement worth trusting | — | `REVDEP2_MEASURED_MAX_AGE_DAYS` | 60 days | -| Check seconds here per CRAN second | — | `REVDEP2_CHECK_SCALE` | measured, else 1 | -| Fixed cost of one shard | — | `REVDEP2_SETUP_MINUTES` | measured, else 6 min | -| Cost of one more dependency install | — | `REVDEP2_INSTALL_SECONDS` | measured, else 2.5 s | -| Per-check timeout factor | — | `REVDEP2_TIMEOUT_FACTOR` | 1.5 × CRAN time | -| Per-check timeout floor | — | `REVDEP2_TIMEOUT_MIN_MINUTES` | 20 | -| Shard graceful deadline | — | `REVDEP2_DEADLINE_MINUTES` | 300 | -| Diff lines printed into the job log per package | — | `REVDEP2_DIFF_MAX_LINES` | 200 | -| Load tests run at once | — | `REVDEP2_LOAD_JOBS` | one per core | -| Time limit on the whole load sweep | — | `REVDEP2_LOAD_SWEEP_MINUTES` | 60 | -| Shard minutes the install may take before the checks get the rest (doubled on a cold library) | — | `REVDEP2_SHARD_INSTALL_MINUTES` | 45 | -| Transcript lines shown per install/test block in the summary | — | `REVDEP2_DETAIL_MAX_LINES` | 300 | - -## Prior art - -Surveyed before building this; what each contributed: - -* [r-lib/revdepcheck](https://github.com/r-lib/revdepcheck) — - the comparison model (old vs new `rcmdcheck`, `compare_checks()`), - the report format, and the `results` injection point the collector uses. - Its `cloud_check()` (one AWS Batch job per package, fetch, compare locally) - is the closest architectural relative. -* [r-devel/recheck](https://github.com/r-devel/recheck) — - CRAN-parity system libraries, binary-first dependency installs, - and the honest framing that revdep results are diagnostics, - too volatile for a pass/fail gate (hence check results never fail the run). -* [yihui/crandalf](https://github.com/yihui/crandalf) — - batching revdeps across CI jobs, - and re-checking only previously failed packages (`retry-run` here). -* [HenrikBengtsson/revdepcheck.extras](https://github.com/HenrikBengtsson/revdepcheck.extras) — - pre-installing the dependency universe before the checks start - (the preflight job). -* duckdb-r's `each.yaml` — - the plan/matrix/fan-in shape, cost-balanced shards under a budget, - graceful deadlines with deferral, per-attempt artifacts, - and empty-matrix/fallback-output hygiene. - -No published workflow was found that balances revdep shards -by CRAN check timings or by dependency overlap; -that part is new here. - -## Not yet validated - -1. Three of the cost model's constants are now fitted from the last runs - (check scale, per-shard setup, per-dependency install); - the per-package overhead (0.5 min) and the budget itself - are still estimates. - The fit is a median, not a regression: - install cost in particular is charged per package - where it plainly is not linear in the package count, - and a shard holding twice as many packages installs a union - that is much less than twice as large. - The measured numbers are in each run's `revdep2-timings`, - so a better model can be fitted whenever the linear one is seen to hurt. -2. Bioconductor revdeps are out of scope: - enumeration, versions and fingerprints all come from CRAN metadata. -3. The report generation leans on unexported revdepcheck internals - (`try_compare_checks()`, `rcmdcheck_error()`) via `:::`, - with a manifest-only fallback when they drift. -4. Baselines live in artifacts, whose retention caps reuse at 90 days - and whose availability is per-repository; - an orphan branch (the `rcc` model) would be durable and fetchable - but grows the repository. -5. Prebuilt-library reuse trades download for build: - every shard fetches each donor library whole, - because artifacts cannot be fetched in part. - That is a clear win where the runner has no binaries to install from - and a marginal one where it does, - and the crossover has not been measured — - `REVDEP2_PREBUILT_MAX_RUNS=0` turns it off if it ever stops paying. diff --git a/.github/workflows/revdep2/build.R b/.github/workflows/revdep2/build.R deleted file mode 100644 index e54d06e..0000000 --- a/.github/workflows/revdep2/build.R +++ /dev/null @@ -1,84 +0,0 @@ -# Build the package under test once: a source tarball and a platform binary, -# so no shard pays the compilation twice. Shards install the binary; the -# tarball is kept alongside for reference and local reproduction. -# -# Deliberately independent of the plan, so the job can run in parallel with -# planning; everything it needs is the checkout. -# -# Environment variables: -# OUT_DIR - where the tarball, binary and metadata land (default: pkg) - -source(file.path( - dirname(sub("--file=", "", grep("^--file=", commandArgs(), value = TRUE))), - "util.R" -)) - -out_dir <- env_chr("OUT_DIR", "pkg") -dir.create(out_dir, recursive = TRUE, showWarnings = FALSE) - -desc <- read.dcf("DESCRIPTION")[1, ] -package <- unname(desc[["Package"]]) -dev_version <- unname(desc[["Version"]]) - -head_sha <- tryCatch( - system2("git", c("rev-parse", "HEAD"), stdout = TRUE, stderr = NULL)[[1]], - error = function(e) "" -) -if (!nzchar(head_sha)) { - head_sha <- env_chr("GITHUB_SHA") -} - -inform("Building ", package, " ", dev_version) -status <- system2("R", c("CMD", "build", "--no-manual", ".")) -if (status != 0) { - stop("R CMD build failed", call. = FALSE) -} -tarball <- sort( - list.files(pattern = sprintf("^%s_.*[.]tar[.]gz$", package)), - decreasing = TRUE -)[[1]] - -inform("Building the binary from ", tarball) -binary_dir <- file.path(out_dir, "bin") -dir.create(binary_dir, recursive = TRUE, showWarnings = FALSE) -build_lib <- tempfile("lib-") -dir.create(build_lib) -status <- system2( - "R", - # Quoted: system2() quotes the command, but not the arguments. - c("CMD", "INSTALL", "--build", "-l", shQuote(build_lib), shQuote(tarball)) -) -if (status != 0) { - stop("R CMD INSTALL --build failed", call. = FALSE) -} -binary <- sort( - list.files(pattern = sprintf("^%s_.*_R_.*[.]tar[.]gz$", package)), - decreasing = TRUE -)[[1]] -file.rename(binary, file.path(binary_dir, binary)) -file.copy(tarball, file.path(out_dir, tarball)) - -write_json( - list( - package = package, - dev_version = dev_version, - sha = head_sha, - r_version = paste( - R.version$major, - sub("[.].*$", "", R.version$minor), - sep = "." - ), - platform = R.version$platform, - tarball = tarball, - binary = file.path("bin", binary), - built_at = now_utc() - ), - file.path(out_dir, "meta.json") -) -inform("Binary: ", binary) - -append_summary(c( - "## revdep2 build", - "", - sprintf("Built `%s` %s: `%s`.", package, dev_version, binary) -)) diff --git a/.github/workflows/revdep2/check-pair.sh b/.github/workflows/revdep2/check-pair.sh deleted file mode 100755 index ceb015c..0000000 --- a/.github/workflows/revdep2/check-pair.sh +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env bash -# Check one package against both versions of the package under test at once. -# -# The two checks are independent -- separate processes, separate check -# directories, and separate library stacks that differ in exactly one package -# -- so there is no reason to run them one after the other. A runner has four -# cores and one `R CMD check` keeps about one of them busy; running the pair -# concurrently halves a package's wall clock and, when one of them hangs, still -# gets the other's answer instead of never reaching it. -# -# The library stacks cascade. `R_LIBS` is a search path, so the shared library -# holding every dependency is named by both, and the version-specific library -# in front of it holds exactly one package: the CRAN release for `old`, the dev -# build for `new`. Nothing is installed or uninstalled between the phases, -# which is what used to force them apart. -# -# Usage: -# check-pair.sh -# -# Leaves /{old,new}/ holding the .Rcheck directory, `driver.log` (what -# R CMD check said) and `status` (its exit code; 124 is the timeout, per -# coreutils `timeout`). Always exits 0: which of the two failed, and how, is -# for the caller to read out of those files. - -set -u - -# The checks run at a lower priority than everything else on the runner. -# -# Two `R CMD check` processes at once, each with children of its own -- a test -# suite that opens a PSOCK cluster, a vignette that knits -- can take every core -# the runner has. The runner agent is a process on that machine too, and it has -# to reach the service regularly or the job dies with -# -# The hosted runner lost communication with the server. -# -# which names starvation as one of its causes. `nice` costs nothing when there -# is headroom: the scheduler only consults priority when there is more work than -# cores, which is exactly the case worth protecting. `ionice` does the same for -# the disk, where a check writing its .Rcheck directory competes with the agent -# writing logs; it is best-effort, since not every image has it. -low_priority=(nice -n 10) -if command -v ionice > /dev/null 2>&1 && ionice -c3 true > /dev/null 2>&1; then - low_priority+=(ionice -c3) -fi - -tarball=$1 -work=$2 -lib_old=$3 -lib_new=$4 -lib_shared=$5 -seconds=$6 - -# Seconds since the check started, in front of every line it prints. -# -# `R CMD check` only reports a stage's own time when it exceeds its threshold, -# and never for the stage it was killed in -- which is the one worth knowing -# about. Stamping the stream costs nothing and turns "timed out at * checking -# examples with --run-donttest" into how long every stage before it took, and -# how long that one had been running. `EPOCHSECONDS` is a bash builtin, so -# this spawns nothing per line. -stamp() { - local start=${EPOCHSECONDS} line - while IFS= read -r line; do - printf '[%5ds] %s\n' "$((EPOCHSECONDS - start))" "${line}" - done -} - -check_one() { - local phase=$1 - local lib=$2 - local port=$3 - local out="${work}/${phase}" - mkdir -p "${out}" - # `timeout` sends TERM at the deadline and KILL a minute later, and R CMD - # check's own children go with it because it runs in its own process group. - # The status is PIPESTATUS[0] because the stamping is downstream of it. - R_LIBS="${lib}:${lib_shared}" \ - R_PARALLEL_PORT="${port}" \ - "${low_priority[@]}" \ - timeout --kill-after=60s "${seconds}s" \ - R CMD check --no-manual --as-cran --output="${out}" "${tarball}" 2>&1 | - stamp > "${out}/driver.log" - echo "${PIPESTATUS[0]}" > "${out}/status" -} - -# A port each, because the two halves would otherwise pick the same one. -# -# `parallel` chooses its default PSOCK port once, when its namespace loads: -# -# ran1 <- sample.int(.Machine$integer.max - 1L, 1L) / .Machine$integer.max -# port <- 11000 + 1000 * ((ran1 + unclass(Sys.time())/300) %% 1) -# -# The random term is drawn from the session's RNG stream, so it is only random -# while the stream is. Anything that calls `set.seed()` before `parallel` is -# first loaded -- which examples, vignettes and testthat do constantly, for -# reproducibility -- makes it deterministic, and both halves then draw the same -# number. Measured: three sessions seeded with 42 gave 11181, 11183, 11183, -# against 11005, 11214, 11652 unseeded. -# -# The time term cannot separate them either. It sweeps 1000 ports over 300 -# seconds -- 3.3 ports per second -- so two halves that load `parallel` within -# a third of a second of each other land on the same integer port. They start -# together and run the same script, so they do. And the choice is made once per -# session, not per cluster, so from then on *every* cluster either half opens -# races for that one port. -# -# In run 31893156685 that cost `cia` (port 11477) and `TDApplied` (11058), -# both reported as newly broken having nothing wrong with them. Staggering the -# halves is not a fix: the separation would have to hold at the moment each -# loads `parallel`, the two drift apart by minutes over a check, and at 300 -# seconds the sweep wraps back onto itself. -# -# Setting the port explicitly costs nothing that was not already the case -- -# R fixes one port per session and reuses it regardless -- and the two ranges -# are far from R's own 11000-12000 band, so a session that inherits neither -# cannot wander into either. -check_one old "${lib_old}" 20000 & -old_pid=$! -check_one new "${lib_new}" 30000 & -new_pid=$! - -wait "${old_pid}" -wait "${new_pid}" - -exit 0 diff --git a/.github/workflows/revdep2/collect.R b/.github/workflows/revdep2/collect.R deleted file mode 100644 index 0943409..0000000 --- a/.github/workflows/revdep2/collect.R +++ /dev/null @@ -1,834 +0,0 @@ -# Fan-in for revdep2: merge every shard's results into one report, one -# manifest, and one baseline for future runs to reuse. -# -# Reads all revdep2-results-* artifacts (every attempt; on a re-run the later -# attempt wins per package), folds in the untouched results of the run being -# retried so the report is always complete, and writes: -# -# revdep/README.md summary, revdepcheck-style -# revdep/problems.md details for packages with new problems -# revdep/failures.md details for packages that could not be checked -# revdep/cran.md the paragraph for cran-comments.md -# revdep/manifest.json one entry per package, machine-readable -# revdep/pkgs/

/ old.rds, new.rds, kept new-version check output -# -# plus the baseline artifact content (baseline.json, old-rds/

.rds): every -# reusable old-version result of this run, stamped with the metadata the next -# plan compares against -- versions, R series, dependency fingerprint, and the -# date the old check *actually* ran (reuse does not refresh it), -# and timings.json: what the checks and the shards actually cost, which is what -# the next plan calibrates its cost model with instead of guessing. -# -# Environment variables: -# RESULTS_DIR - directory the shard artifacts were downloaded into (required) -# PLAN - plan.json (default: plan.json) -# RETRY_DIR - the revdep2-report artifact of the run being retried, if any -# OUT_DIR - report directory (default: revdep) -# BASELINE_OUT - baseline directory (default: baseline) -# TIMINGS_OUT - timings directory (default: timings) -# -# Reads GH_TOKEN, if it has one, only to ask the API how long the shard *jobs* -# took: the part of a shard's cost that happens before its driver starts. -# -# Always exits zero: check results are the report's business, not the job -# status's -- only a genuinely broken collector fails this job. - -source(file.path( - dirname(sub("--file=", "", grep("^--file=", commandArgs(), value = TRUE))), - "util.R" -)) - -results_dir <- env_chr("RESULTS_DIR") -stopifnot(nzchar(results_dir)) -plan <- read_json(env_chr("PLAN", "plan.json")) -retry_dir <- env_chr("RETRY_DIR") -out_dir <- env_chr("OUT_DIR", "revdep") -baseline_out <- env_chr("BASELINE_OUT", "baseline") -timings_out <- env_chr("TIMINGS_OUT", "timings") - -dir.create(file.path(out_dir, "pkgs"), recursive = TRUE, showWarnings = FALSE) -dir.create( - file.path(baseline_out, "old-rds"), - recursive = TRUE, - showWarnings = FALSE -) -dir.create(timings_out, recursive = TRUE, showWarnings = FALSE) - -# ------------------------------------------------------------------- merge --- - -# Shard artifacts are named revdep2-results--; walking them in -# attempt order makes the later attempt win when a shard was re-run. -# -# `download-artifact` only creates the per-artifact subdirectory when it -# downloads more than one: a run planned into a single shard has its -# manifest.ndjson land directly in `results_dir`, not in -# `results_dir/revdep2-results-1-1/`. Run 31930350338 was that run, and the -# collector found one directory (`results/pkgs`), no manifest in it, and -# collected nothing -- then carried all 1011 results over from the run it was -# retrying and committed them as if they were fresh. So the layout is -# discovered rather than assumed: a shard directory is one that has a manifest. -attempt_of <- function(path) { - n <- suppressWarnings(as.integer(sub("^.*-", "", basename(path)))) - if (is.na(n)) 0L else n -} -has_manifest <- function(paths) { - paths[file.exists(file.path(paths, "manifest.ndjson"))] -} -shard_dirs <- has_manifest(list.dirs(results_dir, recursive = FALSE)) -shard_dirs <- shard_dirs[order(vapply(shard_dirs, attempt_of, integer(1)))] -shard_dirs <- c(has_manifest(results_dir), shard_dirs) - -entries <- list() -take <- function(entry, from) { - entry$carried <- isTRUE(entry$carried) - entries[[entry$package]] <<- entry - src <- file.path(from, "pkgs", entry$package) - if (dir.exists(src)) { - dest <- file.path(out_dir, "pkgs", entry$package) - unlink(dest, recursive = TRUE) - dir.create(dest, recursive = TRUE, showWarnings = FALSE) - file.copy(list.files(src, full.names = TRUE), dest, recursive = TRUE) - } -} - -shard_timings <- list() -for (dir in shard_dirs) { - timing <- file.path(dir, "timing.json") - if (file.exists(timing)) { - row <- tryCatch(read_json(timing), error = function(e) NULL) - if (!is.null(row$index)) { - shard_timings[[as.character(row$index)]] <- row - } - } - manifest <- file.path(dir, "manifest.ndjson") - if (!file.exists(manifest)) { - next - } - for (line in readLines(manifest, warn = FALSE)) { - if (nzchar(trimws(line))) { - take(jsonlite::fromJSON(line, simplifyVector = FALSE), dir) - } - } -} -inform( - "Collected ", - length(entries), - " package(s) from ", - length(shard_dirs), - " shard artifact(s)" -) - -# A retried run reports the whole picture: results the retry did not touch are -# carried over from the earlier run's report, marked as such. -if (nzchar(retry_dir) && file.exists(file.path(retry_dir, "manifest.json"))) { - carried <- 0L - for (entry in read_json(file.path(retry_dir, "manifest.json"))) { - if (is.null(entries[[entry$package]])) { - entry$carried <- TRUE - take(entry, retry_dir) - carried <- carried + 1L - } - } - inform( - "Carried ", - carried, - " untouched result(s) over from run ", - plan$retry_of - ) -} - -# Every package the plan named has to appear in the report, including the ones -# whose shard uploaded nothing at all: a job that dies -- runner failure, -# cancellation, the job timeout above the shard's own deadline -- takes its -# manifest with it, and a package silently absent from a report reads as one -# that was fine. `missing` is a not-ok result, so `retry-run` picks exactly -# these up, the same way it picks up a deferral. -missing <- 0L -for (shard in plan$shards %||% list()) { - for (p in shard$packages %||% list()) { - if (!is.null(entries[[p$name]])) { - next - } - entries[[p$name]] <- list( - package = p$name, - version = p$version, - level = p$level %||% 0L, - shard = shard$index, - weight_minutes = p$weight_minutes, - t_total = p$t_total %||% 0, - dep_fingerprint = p$dep_fingerprint, - baseline_planned = isTRUE(p$baseline), - # Matches the shard's own entry shape; `baseline_reused` was dropped when - # both halves became mandatory, and nothing sets it any more. - baseline_agrees = NA, - result = "missing", - status = "", - status_old = "", - status_new = "", - new_issues = 0L, - t_old = NA, - t_new = NA, - old_checked_at = NA, - message = sprintf( - "shard %s uploaded no result for this package; its job did not finish", - shard$index - ), - our_cran_version = plan$cran_version, - our_dev_version = plan$dev_version, - carried = FALSE - ) - missing <- missing + 1L - } -} -if (missing > 0) { - inform( - missing, - " planned package(s) have no result at all; reported as missing" - ) -} - -entries <- entries[order(names(entries))] -results_tbl <- vapply(entries, function(e) e$result, character(1)) - -# ---------------------------------------------------------------- manifest --- - -write_json( - list( - package = plan$package, - dev_version = plan$dev_version, - cran_version = plan$cran_version, - r_version = plan$r_version, - sha = plan$sha, - run_id = env_chr("GITHUB_RUN_ID"), - retry_of = plan$retry_of, - generated_at = now_utc() - ), - file.path(out_dir, "run.json") -) -write_json(unname(entries), file.path(out_dir, "manifest.json")) - -# ---------------------------------------------------------------- baseline --- - -baseline <- list() -for (entry in entries) { - rds <- file.path(out_dir, "pkgs", entry$package, "old.rds") - if ( - !file.exists(rds) || - is.null(entry$old_checked_at) || - is.na(entry$old_checked_at) - ) { - next - } - file.copy( - rds, - file.path(baseline_out, "old-rds", paste0(entry$package, ".rds")) - ) - baseline[[length(baseline) + 1]] <- list( - package = entry$package, - version = entry$version, - our_cran_version = entry$our_cran_version, - r_version = plan$r_version, - dep_fingerprint = entry$dep_fingerprint, - checked_at = entry$old_checked_at, - status_old = entry$status_old, - has_old = TRUE - ) -} -write_json(baseline, file.path(baseline_out, "baseline.json")) -inform("Baseline carries ", length(baseline), " old-version result(s)") - -# ----------------------------------------------------------------- timings --- - -# What this run cost, in the form the next plan can use: one row per package -# (seconds per check here, next to the seconds CRAN reports) and one per shard -# (install, check, script and job minutes, next to what was predicted). -# -# The plan's cost model is three constants -- how fast checks run here, what a -# shard costs before it checks anything, what one more dependency costs to -# install -- and every one of them is measurable. Measuring them is what keeps -# the shard count honest: a model that overestimates the work cuts it into more -# shards than the parallel capacity can run, and each extra shard is another -# setup paid for nothing. -seconds_of <- function(entry) { - both <- suppressWarnings(as.numeric(c(entry$t_old, entry$t_new))) - both <- both[!is.na(both) & both > 0] - if (length(both) == 0) { - NULL - } else { - list(seconds = mean(both), checks = length(both)) - } -} -package_rows <- list() -for (entry in entries) { - measured <- seconds_of(entry) - if (is.null(measured)) { - next - } - package_rows[[length(package_rows) + 1]] <- list( - package = entry$package, - version = entry$version, - t_total = entry$t_total %||% 0, - checks = measured$checks, - seconds = round(measured$seconds, 1) - ) -} - -# The shard's own clock covers install and checks; the minutes before its -# driver starts -- runner image, R, pandoc, TinyTeX, artifact downloads -- are -# only visible from the API, and they are precisely the price of one more -# shard. -job_minutes <- run_shard_job_minutes(env_chr("GITHUB_RUN_ID")) -shard_rows <- lapply(shard_timings, function(t) { - index <- as.character(t$index) - install <- ((t$restore_seconds %||% 0) + (t$install_seconds %||% 0)) / 60 - list( - index = t$index, - packages = t$packages %||% 0, - checks = t$checks %||% 0, - install_packages = t$install_packages %||% 0, - restored = t$restored %||% 0, - install_minutes = round(install, 2), - check_minutes = round((t$check_seconds %||% 0) / 60, 2), - script_minutes = round((t$script_seconds %||% 0) / 60, 2), - job_minutes = if (index %in% names(job_minutes)) { - round(unname(job_minutes[[index]]), 2) - } else { - NULL - }, - planned_minutes = t$planned_minutes, - planned_check_minutes = t$planned_check_minutes - ) -}) -shard_rows <- unname(shard_rows[order(as.numeric(names(shard_rows)))]) - -timings <- list( - run_id = env_chr("GITHUB_RUN_ID"), - generated_at = now_utc(), - r_version = plan$r_version, - platform = R.version$platform, - timing_flavor = plan$timing_flavor, - packages = package_rows, - shards = shard_rows -) -cal <- calibration(list(timings)) -timings$calibration <- list( - check_scale = cal$check_scale, - setup_minutes = cal$setup_minutes, - install_seconds = cal$install_seconds -) -write_json(timings, file.path(timings_out, "timings.json")) -inform( - "Timings: ", - length(package_rows), - " package(s), ", - length(shard_rows), - " shard(s)", - if (length(job_minutes) == 0) " (job durations unavailable)" else "" -) - -# ----------------------------------------------------------------- reports --- - -# The report machinery is revdepcheck's own, fed through its `results` -# injection point; when the package is unavailable the manifest-derived -# summary below still stands on its own. -has_revdepcheck <- requireNamespace("revdepcheck", quietly = TRUE) - -comparison_of <- function(entry) { - dir <- file.path(out_dir, "pkgs", entry$package) - old_path <- file.path(dir, "old.rds") - new_path <- file.path(dir, "new.rds") - shim <- function(message) { - res <- revdepcheck:::rcmdcheck_error( - entry$package, - old = list(stdout = message, stderr = ""), - new = list(stdout = message, stderr = "") - ) - res$version <- entry$version - # `pkg_links()` reads the maintainer and the URL out of - # `result$new$description`, and `desc::desc(text = NULL)` falls back to the - # DESCRIPTION of the working directory -- which here is igraph's own. Left - # alone, a package that never got far enough to have a DESCRIPTION was - # reported with igraph's repository and igraph's maintainer address next to - # its name. A synthetic one carries no maintainer and no URL, so only the - # CRAN mirror link is emitted, and `[UNKNOWN]` is avoided as well. - res$new$version <- entry$version - res$new$description <- sprintf( - "Package: %s\nVersion: %s\n", - entry$package, - entry$version %||% "0" - ) - res$new$cran <- TRUE - res - } - if (!file.exists(old_path) || !file.exists(new_path)) { - message <- if (nzchar(entry$message %||% "")) { - entry$message - } else { - sprintf("Not checked (%s)", entry$result) - } - return(shim(message)) - } - tryCatch( - revdepcheck:::try_compare_checks( - entry$package, - readRDS(old_path), - readRDS(new_path) - ), - error = function(e) shim(conditionMessage(e)) - ) -} - -preamble <- c( - "# Platform", - "", - md_table(data.frame( - field = c("package", "dev", "CRAN", "commit", "R", "platform", "run", "date"), - value = c( - plan$package, - plan$dev_version, - plan$cran_version, - substr(plan$sha, 1, 9), - plan$r_version, - R.version$platform, - env_chr("GITHUB_RUN_ID", "local"), - format(Sys.Date()) - ) - )), - "" -) - -if (has_revdepcheck) { - results <- lapply(unname(entries), comparison_of) - names(results) <- names(entries) - - capture_report <- function(fun, ...) { - path <- tempfile() - fun(..., file = path) - readLines(path, warn = FALSE) - } - writeLines( - c( - preamble, - capture_report(revdepcheck::cloud_report_summary, pkg = ".", results = results) - ), - file.path(out_dir, "README.md") - ) - # `problems.md` and `failures.md` are assembled from one file per package - # rather than written whole. - # - # Two things fall out of that, and the second is why it was done. A diff - # names the package that changed instead of a line range in a file thousands - # of lines long. And a run only has to touch the packages it actually - # checked: a retry of 27 rewrites 27 files and leaves the other 984 exactly - # as the repository has them. Writing the file whole made every run restate - # the entire record, so a run that learnt nothing about a package could still - # rewrite that package's section -- from a shim, if its check output had not - # survived the trip. - # - # Empty is revdepcheck's own wording, so an assembled file with no sections - # reads the way the single-call version did. - no_problems <- "*Wow, no problems at all. :)*" - sections <- list( - problems = revdepcheck::cloud_report_problems, - failures = revdepcheck::cloud_report_failures - ) - for (dir in names(sections)) { - dir.create(file.path(out_dir, dir), showWarnings = FALSE) - } - - # revdepcheck emits one `# ()` block per package that its - # predicate selects, and the sentence above when it selects none. Asking it - # about a single package therefore yields exactly that package's section, or - # nothing. - section_of <- function(fun, package) { - lines <- capture_report(fun, pkg = ".", results = results[package]) - if (identical(trimws(paste(lines, collapse = "")), no_problems)) { - NULL - } else { - lines - } - } - - # A package whose check errors under *both* versions. `ok` is the verdict -- - # there is no new problem, which is what this workflow is for -- but the - # package is broken, and a section someone put in the report for it is not - # made stale by a run that reproduces the breakage on both sides. 79 of run - # 31930350338's 984 `ok` results are of this shape; 55 of them never got as - # far as a check at all (their dependencies would not install, so both - # halves stopped at `checking package dependencies` in a couple of seconds - # and agreed), and 24 are real checks of genuinely broken packages. - # - # This only declines to *delete*; nothing is added. revdepcheck's - # `problems.md` is the newly-broken list by design, and widening it to - # "still broken" is `all = TRUE` and a different report. - # `ok` specifically: a `newly_broken` package can error on both sides too -- - # archeofrag went 1E to 2E in run 31930350338 -- and that one was checked - # here, so its section is this run's to rewrite. - still_broken <- function(entry) { - e <- function(status) { - n <- regmatches( - status %||% "", - regexpr("^[0-9]+(?=E)", status %||% "", perl = TRUE) - ) - length(n) > 0 && as.integer(n) > 0 - } - identical(entry$result, "ok") && e(entry$status_old) && e(entry$status_new) - } - - # What this run is entitled to overwrite. A carried result is one this run - # never checked, and `missing` and `deferred` mean the shard did not get to - # it -- in all three cases the committed section is better evidence than - # anything reconstructible here. The `file.exists` clause makes that a - # preference rather than a rule: with no section on disk there is nothing to - # protect, so it is written from the comparison like any other. - keeps_committed <- function(entry, dir) { - (isTRUE(entry$carried) || - entry$result %in% c("missing", "deferred", "depmissing") || - still_broken(entry)) && - file.exists(file.path(out_dir, dir, paste0(entry$package, ".md"))) - } - - written <- setNames(integer(length(sections)), names(sections)) - for (entry in entries) { - for (dir in names(sections)) { - if (keeps_committed(entry, dir)) { - next - } - path <- file.path(out_dir, dir, paste0(entry$package, ".md")) - lines <- section_of(sections[[dir]], entry$package) - if (is.null(lines)) { - unlink(path) - } else { - writeLines(lines, path) - written[[dir]] <- written[[dir]] + 1L - } - } - } - - # Sorted by file name, so the assembled order is the package order rather - # than however the shards happened to be cut -- case-insensitively, which is - # the order revdepcheck's own single-call version produced and therefore the - # order the committed report is already in. Sorting the raw names instead - # moves `ECoL`, `GoodFitSBM`, `MetaNet` and `R6causal` to the front of - # `problems.md` and rewrites the whole file for nothing. - # - # `method = "radix"` on a lowercased key rather than plain `sort()`: the - # latter collates in the runner's locale, so the committed order would - # depend on where the collector happened to run. Radix is C collation, and - # C collation of the lowercased name is exactly the case-insensitive order. - # The file name is the tie-break, so the sort is total. - for (dir in names(sections)) { - files <- list.files( - file.path(out_dir, dir), - pattern = "[.]md$", - full.names = TRUE - ) - files <- files[ - order(tolower(basename(files)), basename(files), method = "radix") - ] - writeLines( - if (length(files) == 0) { - no_problems - } else { - unlist(lapply(files, readLines, warn = FALSE), use.names = FALSE) - }, - file.path(out_dir, paste0(dir, ".md")) - ) - inform( - dir, - ".md: ", - length(files), - " package(s), ", - written[[dir]], - " written by this run" - ) - } - - writeLines( - capture_report( - revdepcheck::revdep_report_cran, - pkg = ".", - results = results - ), - file.path(out_dir, "cran.md") - ) - inform("Reports written to ", out_dir) -} else { - inform( - "revdepcheck is not installed; writing the manifest-derived summary only" - ) - df <- data.frame( - package = names(entries), - version = vapply(entries, function(e) e$version %||% "?", character(1)), - result = results_tbl, - old = vapply(entries, function(e) e$status_old %||% "", character(1)), - new = vapply(entries, function(e) e$status_new %||% "", character(1)) - ) - writeLines( - c(preamble, "# Revdeps", "", md_table(df)), - file.path(out_dir, "README.md") - ) -} - -# ------------------------------------------------------------------ summary -- - -tally <- function(what) sum(results_tbl == what) -not_ok <- sum(results_tbl != "ok") - -# Whether this report is worth writing over the committed one. -# -# The report in `revdep/` is the repository's record, and `packages: broken` -# reads it back to decide what to re-check. A run in which nothing produced a -# comparison -- every shard dead, every package `missing`, a bad plan, a driver -# bug that turned the whole set into `depfail` -- would replace that record with -# a list of things it never learnt anything about, and there is no way back to -# it. So the run says out loud whether it compared anything at all, and the -# workflow gates the commit on that; the artifact is uploaded either way, so -# nothing is hidden, only the destructive step is skipped. -compared <- tally("ok") + tally("newly_broken") -set_output("compared", compared) -if (compared == 0) { - inform( - "No package produced a comparison; the report is written and uploaded, ", - "but the committed one is left alone" - ) -} - -# The one sentence a reader needs, before any table. -headline <- if (tally("newly_broken") > 0) { - sprintf( - "**%d of %d packages newly broken.**", - tally("newly_broken"), - length(entries) - ) -} else if (not_ok > 0) { - sprintf( - "No new breakage; %d of %d packages could not be fully checked.", - not_ok, - length(entries) - ) -} else { - sprintf("All good: no new problems in %d packages.", length(entries)) -} - -counts_df <- data.frame( - Result = c( - "ok", "newly broken", "failed to check", - # Its own row, and deliberately not counted as a failure: a check killed - # by the clock says nothing about the package, and in the old half it says - # nothing about our change either. - "timed out, not checked", - "dependencies not installable", - # `R CMD check` refused to start, in both halves, because something the - # package needs is not installed. Its own row rather than a failure: the - # package is not broken, it is unknown. - "dependencies unavailable to R CMD check", - "shard error", "deferred", - "no result from its shard" - ), - Packages = c( - tally("ok"), tally("newly_broken"), tally("failed"), - tally("timeout"), - tally("depfail"), tally("depmissing"), tally("error"), tally("deferred"), - tally("missing") - ) -) -counts_df <- counts_df[counts_df$Packages > 0 | counts_df$Result == "ok", ] - -# Packages that produced no comparison at all. revdepcheck lists them too, but -# only as bare names under "Failed to check" -- no version it could resolve and -# no reason, because the shim it is fed carries neither. The manifest has both, -# so that section is dropped from the embedded report and this table takes its -# place. -unchecked <- Filter( - function(e) !e$result %in% c("ok", "newly_broken"), - unname(entries) -) -reason_of <- function(e) { - message <- gsub("[[:space:]]+", " ", trimws(e$message %||% "")) - # Results carried over from an older run predate the shard recording one. - if (!nzchar(message) && nzchar(e$status %||% "")) { - message <- status_message(e$status) - } - if (nzchar(message)) { - return(message) - } - switch( - e$result, - deferred = "the shard hit its deadline before this package was checked", - depfail = "dependencies could not be installed", - depmissing = "R CMD check stopped at `checking package dependencies` under both versions", - missing = "its shard uploaded no results; the job did not finish", - sprintf("no reason recorded (result `%s`)", e$result) - ) -} -unchecked_df <- data.frame( - Package = vapply(unchecked, function(e) cran_link(e$package), character(1)), - Version = vapply(unchecked, function(e) e$version %||% "?", character(1)), - Result = vapply(unchecked, function(e) e$result, character(1)), - Shard = vapply( - unchecked, - function(e) as.character(e$shard %||% ""), - character(1) - ), - Old = vapply(unchecked, function(e) e$status_old %||% "", character(1)), - New = vapply(unchecked, function(e) e$status_new %||% "", character(1)), - Reason = vapply(unchecked, reason_of, character(1)) -) - -# The report itself, nested under this section: headings demoted two levels, -# and the platform preamble dropped -- the sentence above already says what -# was compared against what. -readme <- readLines(file.path(out_dir, "README.md"), warn = FALSE) -revdeps_at <- grep("^# Revdeps", readme)[1] -if (!is.na(revdeps_at)) { - readme <- readme[seq(revdeps_at, length(readme))] -} -readme <- drop_section(readme, "^## Failed to check") -# revdepcheck's tables link into the sibling report files, which is right -# inside the artifact and wrong here: a job summary is served from the run's -# own URL, where `problems.md#pkg` resolves to /actions/runs/problems.md and -# 404s. The package's CRAN page is the reachable equivalent; where the details -# actually live is said once, below. -# Only where the link text is a package name -- the anchor form revdepcheck -# emits for a package. A bare `[failures.md](failures.md)` is a link to the -# report's own file, and rewriting it to `package=failures.md` was nonsense. -readme <- gsub( - "\\[([a-zA-Z][a-zA-Z0-9.]*)\\]\\([^)]*[.]md(#[^)]*)?\\)", - "[\\1](https://cran.r-project.org/package=\\1)", - readme -) -readme <- gsub("^(#+)(\\s)", "##\\1\\2", readme) - -run_id <- env_chr("GITHUB_RUN_ID") -append_summary(c( - "## revdep2 results", - "", - sprintf( - "`%s` %s (dev) vs %s (CRAN), R %s%s.", - plan$package, plan$dev_version, plan$cran_version, plan$r_version, - if (has_run(plan$retry_of)) { - sprintf(", retry of run %s", run_link(plan$retry_of)) - } else { - "" - } - ), - "", - headline, - "", - md_table(counts_df), - "", - readme, - "", - if (nrow(unchecked_df) > 0) { - # A run where everything defers would put every revdep in this table; the - # summary has a size limit, and losing it whole is worse than a cut list. - shown <- utils::head(unchecked_df, 200) - c( - sprintf("### Could not be checked (%d)", nrow(unchecked_df)), - "", - paste( - "No comparison was produced for these, so they say nothing about the", - "dev version either way. The shard job named in `Shard` has the full", - "check log for each." - ), - "", - md_table(shown), - if (nrow(shown) < nrow(unchecked_df)) { - c("", sprintf( - "... and %d more; the full list is `manifest.json` in the report artifact.", - nrow(unchecked_df) - nrow(shown) - )) - }, - "" - ) - }, - # What the run cost, in the terms the next plan is sized in. A plan that - # overestimates buys shards it cannot run in parallel, so these three numbers - # are worth showing next to the results they came from. - if (length(package_rows) > 0 || length(shard_rows) > 0) { - or_unmeasured <- function(x, fmt, ...) { - if (is.null(x)) "not measured" else sprintf(fmt, x, ...) - } - # From the package rows, not the shard rows: a shard whose job died leaves - # no timing of its own, but the checks it did finish are still in the - # manifest the collector just merged. - # `seconds` is the pair's wall clock, not one half's, so it is not - # multiplied by `checks` -- the two ran at the same time. - check_minutes <- sum(vapply( - package_rows, - function(p) p$seconds / 60, - numeric(1) - )) - job <- vapply( - shard_rows, - function(s) s$job_minutes %||% NA_real_, - numeric(1) - ) - c( - "### What this run cost", - "", - md_table(data.frame( - Measured = c( - "Checks", - "Check speed", - "Shard jobs", - "Setup per shard", - "Install per dependency" - ), - Value = c( - sprintf( - "%d in %d package(s), ~%.0f min", - sum(vapply(package_rows, function(p) p$checks, numeric(1))), - length(package_rows), - check_minutes - ), - or_unmeasured(cal$check_scale, "%.2f x the time CRAN reports"), - if (all(is.na(job))) { - sprintf("%d shard(s), job durations unavailable", length(shard_rows)) - } else { - sprintf( - "%d shard(s), median ~%.0f min, longest ~%.0f min", - length(shard_rows), - stats::median(job, na.rm = TRUE), - max(job, na.rm = TRUE) - ) - }, - or_unmeasured( - cal$setup_minutes, - "~%.1f min before the driver starts" - ), - or_unmeasured(cal$install_seconds, "~%.1f s") - ), - check.names = FALSE - )), - "", - sprintf( - "The next plan reads these from the `revdep2-timings` artifact of %s and sizes its shards with them.", - this_run_link("this run") - ), - "" - ) - }, - "### Getting the results", - "", - sprintf( - "The full report -- `problems.md`, `failures.md`, `cran.md` and every check's output -- is the `revdep2-report` artifact of %s.", - this_run_link("this run") - ), - "", - "```sh", - sprintf("gh run download %s --name revdep2-report --dir revdep/", run_id), - "# retry everything that is not ok:", - sprintf("gh workflow run revdep2.yaml -f retry-run=%s", run_id), - "```" -)) - -inform( - length(entries), - " package(s): ", - sum(results_tbl == "ok"), - " ok, ", - not_ok, - " with findings -- see the summary and the revdep2-report artifact" -) diff --git a/.github/workflows/revdep2/fetch.sh b/.github/workflows/revdep2/fetch.sh deleted file mode 100755 index 69ea485..0000000 --- a/.github/workflows/revdep2/fetch.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/sh -# Fetch the results of a revdep2 run into revdep/ and show the summary. -# -# Usage: -# .github/workflows/revdep2/fetch.sh [] [

] -# -# Without a run id, the newest completed revdep2 run of the current repository -# is used. Needs the `gh` CLI, authenticated for the repository. - -set -eu - -run="${1:-}" -dir="${2:-revdep}" - -if [ -z "${run}" ]; then - run="$(gh run list --workflow revdep2.yaml --limit 20 \ - --json databaseId,status --jq \ - '[.[] | select(.status == "completed")][0].databaseId')" - if [ -z "${run}" ] || [ "${run}" = "null" ]; then - echo "No completed revdep2 run found; pass a run id." >&2 - exit 1 - fi - echo "Using newest completed revdep2 run: ${run}" -fi - -mkdir -p "${dir}" -gh run download "${run}" --name revdep2-report --dir "${dir}" - -# What the run cost, next to what it found: this is the file the next plan -# calibrates on, and having it locally makes a dry run reproducible with -# REVDEP2_MEASURED_DIR="${dir}". -gh run download "${run}" --name revdep2-timings --dir "${dir}" || - echo "Run ${run} published no timings artifact." >&2 - -echo -echo "Results of run ${run} are in ${dir}/:" -ls "${dir}" -echo -if [ -f "${dir}/README.md" ]; then - cat "${dir}/README.md" -fi -echo -echo "To re-check everything that is not ok:" -echo " gh workflow run revdep2.yaml -f retry-run=${run}" diff --git a/.github/workflows/revdep2/load-test.sh b/.github/workflows/revdep2/load-test.sh deleted file mode 100755 index 0a1b63d..0000000 --- a/.github/workflows/revdep2/load-test.sh +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env bash -# Load every named package in its own R session, several at a time, each on a -# clock. -# -# Usage: -# load-test.sh -# -# Reads one package name per line. Prints one line per package on stdout: -# -# OK -# FAIL -# -# and the same verdict on stderr as it happens, with a running count: -# -# [load 123/1173] OK red 19s -# -# The two streams are separate on purpose. stdout is the caller's data and is -# captured; stderr is the live log, so a sweep that takes half an hour says -# what it is doing while it does it instead of only afterwards. The caller -# still folds the sorted summary into a collapsed group at the end -- that is -# the one that answers "what was slow", which the arrival order cannot. -# -# Always exits 0: which packages failed is the caller's business, not the -# shell's. -# -# The `OK` lines are the whole log of a step that otherwise says nothing -# between "load-testing 498 packages" and the summary. They are also the only -# place a package that loads *slowly* -- half a minute of `.onLoad`, every -# time anything downstream of it is checked -- ever shows up. The caller folds -# them into a collapsed group, so the cost of the other 497 is a line nobody -# has to scroll past. -# -# Why one session per package rather than batches: -# -# * a batch shares one clock, so one package that hangs spends the whole -# budget and takes 39 innocent packages down with it, and the caller then -# has to re-run each of them alone to find out which. Per package, the -# answer is immediate and the blast radius is one. -# * sessions are independent, so they run at once. A runner has four cores -# and `loadNamespace()` is mostly I/O and dynamic linking, so the wall -# clock falls by about the number of jobs. -# * `timeout` sends TERM at the deadline and KILL a minute later, to the -# process group, so a package whose `.onLoad` blocks on a socket is -# actually killed rather than merely abandoned. - -set -u - -list=$1 -lib=$2 -seconds=$3 -jobs=$4 - -total=$(grep -c . "${list}" || true) -width=${#total} - -# The running count, without a lock. Every finished package appends one byte -# and reads the size back; single-byte appends to an O_APPEND descriptor do not -# interleave, so the number is exact rather than approximately right. A stale -# count would be cosmetic either way -- it is a progress indicator, not data. -progress=$(mktemp) -trap 'rm -f "${progress}"' EXIT - -# One package, one session, one clock. `--vanilla` so nothing in a profile -# loads anything this is supposed to be testing. -load_one() { - local pkg=$1 status=0 start=${EPOCHSECONDS} - timeout --kill-after=60s "${seconds}s" \ - Rscript --vanilla -e \ - ".libPaths(c('${lib}', .libPaths())); loadNamespace('${pkg}')" \ - > /dev/null 2>&1 || status=$? - local took=$((EPOCHSECONDS - start)) verdict - if [ "${status}" -eq 0 ]; then - verdict=OK - echo "OK ${pkg} ${took}" - # 124 is coreutils' timeout; anything else is R saying something. - elif [ "${status}" -eq 124 ] || [ "${status}" -eq 137 ]; then - verdict=TIMEOUT - echo "FAIL ${pkg} timeout ${took}" - else - verdict=ERROR - echo "FAIL ${pkg} error ${took}" - fi - printf '.' >> "${progress}" - printf '[load %*d/%d] %-7s %-32s %ss\n' \ - "${width}" "$(wc -c < "${progress}")" "${total}" \ - "${verdict}" "${pkg}" "${took}" >&2 -} -export -f load_one -export lib seconds progress total width - -# GNU parallel where it exists, `xargs -P` where it does not -- the runners -# have both, but a local invocation may not, and the two are interchangeable -# for this. -if command -v parallel > /dev/null 2>&1; then - parallel --will-cite -j "${jobs}" load_one :::: "${list}" -else - xargs -a "${list}" -r -n 1 -P "${jobs}" -I '{}' \ - bash -c 'load_one "$@"' _ '{}' -fi - -exit 0 diff --git a/.github/workflows/revdep2/plan.R b/.github/workflows/revdep2/plan.R deleted file mode 100644 index d860a23..0000000 --- a/.github/workflows/revdep2/plan.R +++ /dev/null @@ -1,1418 +0,0 @@ -# Plan the sharded reverse-dependency check. -# -# Enumerates the reverse dependencies of the package in the current directory, -# weighs each one by what its check is expected to cost on these runners, -# decides which CRAN-baseline results from an earlier run can be reused, and -# partitions the packages into cost-balanced shards. One shard becomes one -# matrix leg of .github/workflows/revdep2.yaml. -# -# The partitioning is greedy, in two phases (see revdep2/README.md for why -# greedy beats an exact formulation here): -# -# 1. The K heaviest packages are dealt round-robin, one per shard, so no two -# giants share a leg. -# 2. Every remaining package, heaviest first, goes to the shard where its -# marginal cost is smallest: its own check weight plus an install penalty -# for each dependency the shard does not already need. The penalty is what -# pulls packages with overlapping dependency trees onto the same shard. -# -# K is bounded by the parallel capacity, not by the budget alone. Only -# `max-parallel` shards ever run at once, so shard K+1 of a full wave does not -# start any earlier for having been split off -- it just pays another setup. -# The rule is therefore: as many shards as the budget wants while they all fit -# in one wave, and beyond that, whole waves -- as many as the per-shard -# capacity demands, and no more. -# -# The cost model behind all of it -- how fast a check runs here, what a shard -# costs before it checks anything, what one more dependency costs to install -- -# is calibrated from the timings artifact of the last runs, and falls back to -# CRAN's numbers and the defaults below when no run has measured anything yet. -# -# Environment variables (inputs): -# REVDEP2_PACKAGES - explicit packages to check (comma/space separated; -# default: all reverse dependencies), or the word -# `broken` to take them from the committed report -# REVDEP2_WHICH - "strong" (default) or "most" (adds Suggests/ -# Enhances dependents) -# REVDEP2_RETRY_RUN - run id of an earlier revdep2 run; check only the -# packages that run could not declare ok -# REVDEP2_PART - "i/G": check one G-th of the batch, for a revdep -# set too big for a single run (the plan refuses -# such a batch and prints the G it needs) -# REVDEP2_RECHECK_REPORT - if truthy, check what the committed report lists -# as broken or failed (same as REVDEP2_PACKAGES=broken) -# REVDEP2_REPORT_DIR - where that report lives (default: revdep) -# REVDEP2_SHARD_BUDGET_MINUTES - check-time target per shard (default: 45) -# REVDEP2_SHARD_CAPACITY_MINUTES - check minutes one shard may be given at -# most, which is what forces a second wave -# (default: 80% of REVDEP2_DEADLINE_MINUTES) -# REVDEP2_LONG_RUN_HOURS - estimated wall clock past which the plan warns in -# the job summary; it never refuses for length -# alone (default: 12) -# REVDEP2_MAX_SHARDS - matrix legs to emit at most (default: 250) -# REVDEP2_MAX_PARALLEL - legs to run concurrently, and so the size of one -# wave (default: 20) -# REVDEP2_REFRESH_BASELINE- if truthy, ignore reusable baselines and re-check -# the CRAN version of everything -# REVDEP2_BASELINE_MAX_AGE_DAYS - oldest baseline worth reusing (default: 30) -# REVDEP2_PREBUILT_MAX_RUNS - earlier runs whose prebuilt package libraries -# this run may reuse (default: 5; 0 disables) -# REVDEP2_PREBUILT_MAX_AGE_DAYS - oldest prebuilt library worth reusing -# (default: 14) -# REVDEP2_HISTORY_RUNS - earlier runs the donor walk looks at at all -# (default: 40) -# REVDEP2_PREFLIGHT_MIN_SHARDS - shards a package must be needed by before -# the preflight installs it centrally; 1 is the -# whole universe (default: 2) -# REVDEP2_MEASURED_MAX_RUNS - earlier runs whose measured timings calibrate -# the cost model (default: 3; 0 disables) -# REVDEP2_MEASURED_MAX_AGE_DAYS - oldest measurement worth trusting -# (default: 60) -# REVDEP2_MEASURED_DIR - offline hook: a directory holding a timings.json, -# used instead of walking the run history -# REVDEP2_CHECK_SCALE - check seconds here per second CRAN reports; -# overrides the measured value (default: measured, -# else 1) -# REVDEP2_SETUP_MINUTES - fixed cost of one shard before it checks anything; -# overrides the measured value (default: measured, -# else 6) -# REVDEP2_INSTALL_SECONDS - marginal install cost charged per dependency a -# package adds to its shard; overrides the measured -# value (default: measured, else 2.5) -# REVDEP2_TIMINGS_FILE - offline hook: RDS or CSV with columns Package and -# T_total, used instead of tools::CRAN_check_results() -# OUT - plan file to write (default: plan.json) -# -# Also reads GITHUB_REPOSITORY / GITHUB_SHA / GITHUB_REF_NAME and, for baseline -# discovery, uses the `gh` CLI with GH_TOKEN. Without gh or a token the plan -# simply reuses nothing. - -source(file.path( - dirname(sub("--file=", "", grep("^--file=", commandArgs(), value = TRUE))), - "util.R" -)) - -out_path <- env_chr("OUT", "plan.json") -which_input <- match.arg( - env_chr("REVDEP2_WHICH", "strong"), - c("strong", "most") -) -depth_raw <- tolower(env_chr("REVDEP2_DEPTH", "1")) -depth <- if (depth_raw %in% c("all", "max", "inf", "infinity")) { - Inf -} else { - suppressWarnings(as.numeric(depth_raw)) -} -if (is.na(depth) || depth < 1) { - depth <- 1 -} -budget <- env_num("REVDEP2_SHARD_BUDGET_MINUTES", 45) -max_shards <- min(env_num("REVDEP2_MAX_SHARDS", 250), 250) -max_parallel <- env_num("REVDEP2_MAX_PARALLEL", 20) -# A shard stops starting checks at its own deadline and defers the rest, so the -# deadline is what actually caps a shard's check load; the plan aims below it, -# leaving the rest of the job for installing and for the checks running long. -deadline_minutes <- env_num("REVDEP2_DEADLINE_MINUTES", 300) -capacity <- env_num("REVDEP2_SHARD_CAPACITY_MINUTES", 0.8 * deadline_minutes) -refresh_baseline <- env_flag("REVDEP2_REFRESH_BASELINE") -baseline_max_age <- env_num("REVDEP2_BASELINE_MAX_AGE_DAYS", 30) -max_prebuilt_runs <- env_num("REVDEP2_PREBUILT_MAX_RUNS", 5) -prebuilt_max_age <- env_num("REVDEP2_PREBUILT_MAX_AGE_DAYS", 14) -history_runs <- env_num("REVDEP2_HISTORY_RUNS", 40) -max_measured_runs <- env_num("REVDEP2_MEASURED_MAX_RUNS", 3) -measured_max_age <- env_num("REVDEP2_MEASURED_MAX_AGE_DAYS", 60) -recheck_report <- env_flag("REVDEP2_RECHECK_REPORT") -report_dir <- env_chr("REVDEP2_REPORT_DIR", "revdep") -overhead_minutes <- env_num("REVDEP2_PACKAGE_OVERHEAD_MINUTES", 0.5) -retry_run <- env_chr("REVDEP2_RETRY_RUN") -repo <- env_chr("GITHUB_REPOSITORY") -timing_flavor <- env_chr("REVDEP2_TIMING_FLAVOR", "r-release-linux-x86_64") - -r_version <- paste( - R.version$major, - sub("[.].*$", "", R.version$minor), - sep = "." -) - -# ------------------------------------------------------------ empty plans ---- - -plan_nothing <- function(reason) { - inform("Planning nothing: ", reason) - set_output("matrix", '{"shard":["none"]}') - set_output("shards", "0") - set_output("packages", "0") - set_output("max_parallel", "1") - set_output("baseline_run", "0") - set_output("plan_hash", "none") - append_summary(c("## revdep2 plan", "", paste0("Nothing to check: ", reason))) - quit(save = "no", status = 0) -} - -# ------------------------------------------------------------ run history ---- - -# The gh plumbing itself lives in util.R, because the preflight and the shards -# fetch artifacts too; what is planned here is *which* earlier runs to take -# them from. -# -# One walk over the workflow's completed runs, youngest first, answers every -# question this plan asks of its history, and asks the API for a run's -# artifacts at most once: -# -# * which run donates the CRAN baseline -- the newest one that still has it; -# * which runs donate prebuilt package libraries -- as many as it takes to -# cover everything this run installs, youngest first, each one credited -# only with what the younger ones did not already have; -# * which runs donate measured timings -- the youngest few, whose numbers -# calibrate the cost model below. -# -# The walk stops as soon as it has all of them, and never looks at more than -# `history_runs` runs; reuse is an optimization, and an optimization does not -# get to spend the planning budget. -scan_history <- function(want_baseline, want_timings, needed) { - empty <- list( - baseline_run = NULL, - prebuilt = list(), - timings = list(), - timings_runs = character(), - scanned = 0L, - missing = needed - ) - if (!gh_ok() || !nzchar(repo)) { - return(empty) - } - rows <- gh_lines( - "api", - sprintf( - "repos/%s/actions/workflows/revdep2.yaml/runs?status=completed&per_page=%d", - repo, - history_runs - ), - "--jq", - ".workflow_runs[] | [.id, .created_at] | @tsv" - ) - rows <- rows[nzchar(rows)] - if (length(rows) == 0) { - return(empty) - } - this_run <- env_chr("GITHUB_RUN_ID") - baseline_run <- NULL - prebuilt <- list() - timings <- list() - timings_runs <- character() - scanned <- 0L - for (row in rows) { - if ( - !want_baseline && - length(timings) >= want_timings && - (length(needed) == 0 || length(prebuilt) >= max_prebuilt_runs) - ) { - break - } - fields <- strsplit(row, "\t", fixed = TRUE)[[1]] - run <- fields[[1]] - if (identical(run, this_run)) { - next - } - created <- suppressWarnings(as.Date(fields[[2]])) - scanned <- scanned + 1L - ids <- run_artifacts(run) - artifacts <- names(ids) - if (want_baseline && "revdep2-baseline" %in% artifacts) { - baseline_run <- run - want_baseline <- FALSE - } - # Timings age the way baselines do: a runner image moves, and with it what - # a check costs. They are tiny, so taking the youngest few and pooling them - # is cheaper than trusting a single run that may have been a small retry. - take_timings <- length(timings) < want_timings && - "revdep2-timings" %in% artifacts && - !is.na(created) && - as.numeric(Sys.Date() - created) <= measured_max_age - if (take_timings) { - dir <- fetch_artifact_id(ids[["revdep2-timings"]], tempfile("timings-")) - measured <- read_timings(dir) - unlink(dir, recursive = TRUE) - if ( - !is.null(measured) && - identical(measured$platform, R.version$platform) - ) { - timings[[length(timings) + 1]] <- measured - timings_runs <- c(timings_runs, run) - } - } - # A library is only worth carrying while its binaries still match the R - # series and the platform they were built for, and while the runner image - # they were built on is plausibly the current one -- which is what the age - # cap stands in for, the same way it does for baselines. - take_library <- length(needed) > 0 && - length(prebuilt) < max_prebuilt_runs && - all(c("revdep2-lib", "revdep2-lib-index") %in% artifacts) && - !is.na(created) && - as.numeric(Sys.Date() - created) <= prebuilt_max_age - if (take_library) { - dir <- fetch_artifact_id( - ids[["revdep2-lib-index"]], - tempfile("lib-index-") - ) - index_path <- if (is.null(dir)) NULL else file.path(dir, "lib.json") - index <- if (!is.null(index_path) && file.exists(index_path)) { - read_json(index_path) - } else { - NULL - } - unlink(dir, recursive = TRUE) - if ( - !is.null(index) && - identical(index$r_version, r_version) && - identical(index$platform, R.version$platform) - ) { - have <- vapply(index$packages, function(e) e$package, character(1)) - gain <- intersect(needed, have) - if (length(gain) > 0) { - prebuilt[[length(prebuilt) + 1]] <- list( - run_id = run, - created_at = fields[[2]], - packages = as.list(gain) - ) - needed <- setdiff(needed, gain) - } - } - } - } - list( - baseline_run = baseline_run, - prebuilt = prebuilt, - timings = timings, - timings_runs = timings_runs, - scanned = scanned, - missing = needed - ) -} - -# ------------------------------------------------- the package under test ---- - -desc <- read.dcf("DESCRIPTION")[1, ] -package <- unname(desc[["Package"]]) -dev_version <- unname(desc[["Version"]]) -inform("Package under test: ", package, " ", dev_version) - -db <- cran_db() -if (!package %in% rownames(db)) { - plan_nothing(sprintf( - "%s is not on CRAN, so it has no CRAN reverse dependencies", - package - )) -} -cran_version <- unname(db[package, "Version"]) -inform("CRAN version: ", cran_version) - -# ------------------------------------------------------------- enumeration --- - -# Breadth-first over reverse dependencies: level 1 depends on the package -# directly, level 2 on a level-1 package, and so on. Deeper levels break -# through their intermediaries, so checking them still compares CRAN vs dev -# meaningfully. The walk stops at `depth`, or at the fixpoint for "all". -level_of <- integer() -frontier <- package -level <- 0L -while (level < depth && length(frontier) > 0) { - found <- tools::package_dependencies( - frontier, - db = db, - which = if (which_input == "most") "most" else "strong", - reverse = TRUE - ) - fresh <- setdiff( - unique(unlist(found, use.names = FALSE)), - c(names(level_of), package) - ) - level <- level + 1L - level_of[fresh] <- level - frontier <- fresh -} -revdeps <- sort(names(level_of)) -level_counts <- table(level_of) -inform( - length(revdeps), - " reverse dependencies (", - which_input, - ", depth ", - depth_raw, - if (length(level_counts) > 1) { - paste0( - "; ", - paste0("level ", names(level_counts), ": ", level_counts, collapse = ", ") - ) - } else { - "" - }, - ")" -) - -# `selection` goes into plan.json, so it stays plain; `selection_md` is the -# same thing with the run id clickable, for the job summary. -selection <- "all" -selection_md <- NULL -retry_manifest <- NULL -packages_input <- trimws(strsplit( - env_chr("REVDEP2_PACKAGES"), - "[,[:space:]]+" -)[[1]]) -packages_input <- packages_input[nzchar(packages_input)] - -# `packages: broken` is a selector, not a package name: the dispatch form has -# room for few inputs, and "what was wrong last time" belongs with "what to -# check" rather than beside it. -if ( - length(packages_input) == 1 && - tolower(packages_input) %in% c("broken", "failed", "report") -) { - recheck_report <- TRUE - packages_input <- character() -} - -if (length(packages_input) > 0) { - selection <- "explicit" - candidates <- unique(packages_input) -} else if (recheck_report) { - # The committed report is the durable record of what was wrong last time: - # every package it lists as a problem or a failure, re-checked. This is the - # `revdep/run-broken.R` loop that predates this workflow, as an input. - found <- report_packages(report_dir) - if (length(found$packages) == 0) { - plan_nothing(sprintf( - "%s lists no broken or failed packages (looked for manifest.json, problems.md, failures.md, README.md)", - report_dir - )) - } - selection <- sprintf("broken and failed in %s", report_dir) - candidates <- found$packages - inform( - "Re-checking ", - length(candidates), - " package(s) from ", - report_dir, - " (", - found$source, - ")" - ) -} else if (nzchar(retry_run)) { - selection <- sprintf("retry of run %s", retry_run) - selection_md <- sprintf("retry of run %s", run_link(retry_run)) - dir <- fetch_artifact(retry_run, "revdep2-report", tempfile("retry-")) - manifest_path <- if (is.null(dir)) NULL else file.path(dir, "manifest.json") - if (is.null(manifest_path) || !file.exists(manifest_path)) { - stop( - "Cannot fetch the revdep2-report artifact of run ", - retry_run, - call. = FALSE - ) - } - retry_manifest <- read_json(manifest_path) - results <- vapply(retry_manifest, function(e) e$result, character(1)) - candidates <- vapply(retry_manifest, function(e) e$package, character(1))[ - vapply(results, needs_recheck, logical(1)) - ] - inform( - "Retrying ", - length(candidates), - " of ", - length(retry_manifest), - " packages from run ", - retry_run - ) -} else { - candidates <- revdeps -} - -dropped <- setdiff(candidates, rownames(db)) -if (length(dropped) > 0) { - inform("Not on CRAN, dropped: ", paste(dropped, collapse = ", ")) -} -packages <- intersect(candidates, rownames(db)) -if (length(packages) == 0) { - plan_nothing("no packages left to check") -} -their_version <- setNames(unname(db[packages, "Version"]), packages) - -# ------------------------------------------------------------------ weights -- - -timings_file <- env_chr("REVDEP2_TIMINGS_FILE") -if (nzchar(timings_file)) { - inform("Reading check timings from ", timings_file) - timings <- if (grepl("[.]rds$", timings_file)) { - readRDS(timings_file) - } else { - utils::read.csv(timings_file) - } -} else { - inform("Fetching CRAN check timings (flavor ", timing_flavor, ")") - timings <- tools::CRAN_check_results() - timings <- timings[timings$Flavor == timing_flavor, c("Package", "T_total")] -} -t_total <- setNames( - as.numeric(timings$T_total)[match(packages, timings$Package)], - packages -) -known <- !is.na(t_total) -fallback <- if (any(known)) stats::median(t_total[known]) else 300 -t_total[!known] <- fallback -t_total <- pmax(t_total, 60) - -# ---------------------------------------------------------------- parts ----- - -# `part: i/G` takes one G-th of the batch, for a revdep set too big to check in -# one run (see the refusal in the partitioning section, which computes G and -# prints the dispatch lines). The cut is made here, on CRAN's times, because -# everything downstream -- closures, the dependency universe, the prebuilt -# lookup -- should see only the packages this run will check. -# -# Dealing the weight-ordered list round robin keeps the parts of similar size -# without any coordination between the runs: each one re-derives the same -# order from the same CRAN metadata. A package that moves between dispatches -# can land in another part or in none; `retry-run` on the union is the sweep -# for that, and nothing here depends on the parts being exact. -part_input <- trimws(env_chr("REVDEP2_PART")) -part <- NULL -if (nzchar(part_input)) { - fields <- suppressWarnings(as.integer(strsplit(part_input, "/")[[1]])) - if ( - length(fields) != 2 || - anyNA(fields) || - fields[[1]] < 1 || - fields[[2]] < 1 || - fields[[1]] > fields[[2]] - ) { - stop( - "REVDEP2_PART must be `i/G` with 1 <= i <= G, not ", - part_input, - call. = FALSE - ) - } - part <- list(index = fields[[1]], of = fields[[2]]) - mine <- order(-t_total)[ - seq(part$index, length(packages), by = part$of) - ] - packages <- sort(packages[mine]) - t_total <- t_total[packages] - known <- known[packages] - their_version <- their_version[packages] - suffix <- sprintf(", part %d of %d", part$index, part$of) - selection <- paste0(selection, suffix) - if (!is.null(selection_md)) { - selection_md <- paste0(selection_md, suffix) - } - inform( - "Part ", - part$index, - " of ", - part$of, - ": ", - length(packages), - " packages, ~", - round(sum(t_total) / 60), - " CRAN check minutes" - ) - if (length(packages) == 0) { - plan_nothing(sprintf("part %d of %d is empty", part$index, part$of)) - } -} -inform( - sum(known), - " of ", - length(packages), - " check times known from CRAN; ", - "median fallback ", - round(fallback), - "s for the rest" -) - -# --------------------------------------------------------------- closures ---- - -inform("Computing dependency closures") -closure <- install_closure(packages, db) -fingerprint <- vapply( - packages, - function(p) dep_fingerprint(closure[[p]], db), - character(1) -) - -# The dev version's own dependencies: every shard installs the dev binary, so -# every shard needs them even when no revdep pulls them in. Parsed from the -# checkout's DESCRIPTION, resolved against CRAN. -parse_dep_field <- function(field) { - value <- desc[field] - if (is.na(value)) { - return(character()) - } - entries <- strsplit(value, ",")[[1]] - names <- trimws(sub("[([].*$", "", entries)) - names[nzchar(names) & names != "R"] -} -dev_deps <- unique(unlist(lapply( - c("Depends", "Imports", "LinkingTo"), - parse_dep_field -))) -dev_deps <- intersect(dev_deps, rownames(db)) -dev_closure <- sort(setdiff( - unique(c( - dev_deps, - unlist( - tools::package_dependencies(dev_deps, db = db, which = "strong", recursive = TRUE), - use.names = FALSE - ) - )), - base_packages() -)) - -# Everything this run installs anywhere: the union of the revdeps' closures -# and the dev version's own dependencies. It prices the partitioning penalty -# below, and it is the set the prebuilt libraries of earlier runs are matched -# against. -universe <- unique(c(unlist(closure, use.names = FALSE), dev_closure)) - -# ------------------------------------------------------------ earlier runs --- - -local_baseline <- env_chr("REVDEP2_BASELINE_DIR") -local_measured <- env_chr("REVDEP2_MEASURED_DIR") -history <- scan_history( - # A retried run donates its own baseline, and the offline hooks bypass - # discovery entirely; whatever is supplied that way, the walk stops looking - # for. - want_baseline = !refresh_baseline && - !nzchar(local_baseline) && - !nzchar(retry_run), - want_timings = if (nzchar(local_measured)) 0 else max_measured_runs, - needed = universe -) - -# ---------------------------------------------------------------- baseline --- - -baseline_run <- "0" -baseline_manifest <- list() -if (refresh_baseline) { - inform("Baseline reuse disabled by input") -} else if (nzchar(local_baseline)) { - # Offline hook for testing the eligibility rules without a GitHub run: a - # directory holding baseline.json, e.g. a downloaded revdep2-baseline - # artifact. The shard reads the same directory through BASELINE_DIR. - manifest_path <- file.path(local_baseline, "baseline.json") - if (file.exists(manifest_path)) { - entries <- read_json(manifest_path) - baseline_manifest <- setNames( - entries, - vapply(entries, function(e) e$package, character(1)) - ) - inform( - "Baseline from ", - local_baseline, - " (", - length(baseline_manifest), - " entries)" - ) - } -} else { - donor <- if (nzchar(retry_run)) retry_run else history$baseline_run - if (is.null(donor) || !nzchar(donor)) { - inform("No earlier run with a baseline artifact found") - } else { - dir <- fetch_artifact(donor, "revdep2-baseline", tempfile("baseline-")) - manifest_path <- if (is.null(dir)) NULL else file.path(dir, "baseline.json") - if (is.null(manifest_path) || !file.exists(manifest_path)) { - inform( - "Baseline artifact of run ", - donor, - " is unavailable; reusing nothing" - ) - } else { - baseline_run <- run_id_chr(donor) - entries <- read_json(manifest_path) - baseline_manifest <- setNames( - entries, - vapply(entries, function(e) e$package, character(1)) - ) - inform( - "Baseline donor: run ", - donor, - " (", - length(baseline_manifest), - " entries)" - ) - } - } -} - -# Reuse an old-version verdict only when everything that shaped it is -# unchanged: the revdep's version, the CRAN version of the package under test, -# the R series, and the resolved versions of the whole install closure -- plus -# an age cap as the backstop for what metadata cannot see (system libraries, -# the runner image). -baseline_verdict <- function(p) { - e <- baseline_manifest[[p]] - if (is.null(e)) { - return("none") - } - if (!identical(e$version, unname(their_version[[p]]))) { - return("their-version") - } - if (!identical(e$our_cran_version, cran_version)) { - return("our-version") - } - if (!identical(e$r_version, r_version)) { - return("r-version") - } - if (!identical(e$dep_fingerprint, unname(fingerprint[[p]]))) { - return("dependencies") - } - checked <- suppressWarnings(as.Date(e$checked_at)) - if (is.na(checked) || as.numeric(Sys.Date() - checked) > baseline_max_age) { - return("age") - } - if (!isTRUE(e$has_old)) { - return("missing-rds") - } - "reuse" -} -verdicts <- vapply(packages, baseline_verdict, character(1)) -reuse <- verdicts == "reuse" -if (has_run(baseline_run)) { - stale <- table(verdicts[!reuse]) - inform( - "Baseline: ", - sum(reuse), - " reusable, ", - sum(!reuse), - " to check fresh", - if (length(stale) > 0) { - paste0(" (", paste(names(stale), stale, sep = ": ", collapse = ", "), ")") - } else { - "" - } - ) -} - -# --------------------------------------------------------------- prebuilt --- - -# What the preflight and the shards will unpack instead of building. Recording -# it here rather than letting every job walk the history itself keeps the -# decision in one place, makes it inspectable in the plan and the summary, and -# spends the API calls once. -prebuilt <- history$prebuilt -prebuilt_covered <- length(universe) - length(history$missing) -if (length(prebuilt) > 0) { - inform( - "Prebuilt libraries: ", - prebuilt_covered, - " of ", - length(universe), - " packages from ", - length(prebuilt), - " run(s) (", - paste( - vapply( - prebuilt, - function(d) sprintf("%s: %d", d$run_id, length(d$packages)), - character(1) - ), - collapse = ", " - ), - ")" - ) -} else if (max_prebuilt_runs > 0) { - inform( - "No reusable prebuilt package library found; everything is installed fresh" - ) -} - -# ------------------------------------------------------------- calibration --- - -# CRAN's `T_total` ranks packages well and predicts minutes here badly: it -# comes from a different machine under a different load, and it is the only -# number available for a package this workflow has never checked. So the last -# runs' own measurements come first, and CRAN's number is scaled by what those -# runs say the ratio between the two is. -# -# Every constant is overridable by hand, and every fallback is the value that -# was hard-coded before anything measured itself. -measured_runs <- if (nzchar(local_measured)) { - # Offline hook for reading a downloaded revdep2-timings artifact, the way - # REVDEP2_BASELINE_DIR reads a downloaded baseline. - Filter(Negate(is.null), list(read_timings(local_measured))) -} else { - history$timings -} -cal <- calibration(measured_runs) -measured_seconds <- measured_check_seconds(measured_runs) - -check_scale <- env_num_opt("REVDEP2_CHECK_SCALE") %||% cal$check_scale %||% 1 -setup_minutes <- env_num_opt("REVDEP2_SETUP_MINUTES") %||% - cal$setup_minutes %||% - 6 -install_seconds <- env_num_opt("REVDEP2_INSTALL_SECONDS") %||% - cal$install_seconds %||% - 2.5 - -if (length(measured_runs) > 0) { - inform( - sprintf( - "Calibrated from %d run(s) (%s): checks run at %.2fx their CRAN time, %.1f min setup per shard, %.1f s per dependency installed", - length(measured_runs), - paste( - if (nzchar(local_measured)) local_measured else history$timings_runs, - collapse = ", " - ), - check_scale, - setup_minutes, - install_seconds - ) - ) -} else { - inform( - "No measured timings found; using CRAN check times as they are, with the default shard costs" - ) -} - -# What one check of each package is expected to cost *here*: what the last runs -# measured, or CRAN's time scaled to this machine. The floor keeps a package -# with an implausibly small measurement from looking free. -check_seconds <- t_total * check_scale -seen <- intersect(packages, names(measured_seconds)) -check_seconds[seen] <- measured_seconds[seen] -check_seconds <- pmax(check_seconds, 30) -timing_source <- ifelse( - packages %in% seen, - "measured", - ifelse(known, "cran", "median") -) -inform( - sum(packages %in% seen), - " of ", - length(packages), - " check times measured by an earlier run" -) - -# Weight: what one package costs the shard in wall clock, which is one *pair* -# of checks. -# -# This used to be `((!reuse) + 1) *`: a reusable baseline stood in for the old -# check, so such a package cost one check and everything else cost two. Both -# halves always run now, so the condition is gone -- but so is the factor of -# two, and that part is easy to get backwards. The two halves run -# *concurrently*, so a package costs the shard the wall clock of the slower -# one, not the sum. And `check_scale` is fitted from exactly that quantity: -# `collect.R` records `t_old` and `t_new` as the pair's wall clock and -# `calibration()` fits `median(seconds / T_total)` from it, so -# `check_seconds` already *is* the pair. Multiplying by two here would price -# every shard at twice its wall clock -- which buys twice the shards, each -# paying its own setup, and defers packages at the deadline that would have -# fit. -weight <- check_seconds / 60 + overhead_minutes - -# ------------------------------------------------------------- partitioning -- - -n <- length(packages) -total_check <- sum(weight) - -# How many shards can actually run at the same time. Everything past that waits -# for a lane, so the shard count is counted in waves of this size. -lanes <- max(1L, min(as.integer(max_parallel), as.integer(max_shards), n)) - -# Two demands, and they do not agree once the batch is large: -# -# * the budget wants shards of at most `budget` check minutes -- short legs, -# quick feedback, cheap re-runs; -# * the capacity says a shard can hold `capacity` check minutes before its -# own deadline starts deferring packages. -# -# While the budget's answer fits in one wave, it wins: those shards all start -# at once, so cutting finer really does buy wall clock. Past that it stops -# buying anything -- shard 21 of 40 waits for shard 1 to finish either way, and -# arrives having paid a second setup for the privilege. So beyond one wave the -# capacity decides, and it decides in whole waves: as many as it takes to keep -# every shard under its deadline, and not one more. -by_budget <- max(1L, as.integer(ceiling(total_check / budget))) -by_capacity <- max(1L, as.integer(ceiling(total_check / max(capacity, 1)))) -# Neither dial may be violated inside a wave, so the larger of the two wins -# there; a capacity smaller than the budget is a contradiction, and the one -# that keeps shards inside their deadline is the one to honour. -k <- if (max(by_budget, by_capacity) <= lanes) { - max(by_budget, by_capacity) -} else { - lanes * as.integer(ceiling(by_capacity / lanes)) -} -max_k <- max(1L, min(as.integer(max_shards), n)) -k <- max(1L, min(k, max_k)) -inform( - sprintf( - "%d packages, ~%.0f check minutes; budget %.0f min asks for %d shard(s), capacity %.0f min needs %d, %d lane(s) -> %d shard(s), ~%.0f check min each", - n, - total_check, - budget, - by_budget, - capacity, - by_capacity, - lanes, - k, - total_check / k - ) -) - -dep_idx <- lapply(closure, function(deps) match(deps, universe)) -penalty <- install_seconds / 60 - -ord <- order(-weight) - -# The greedy pass, for a given shard count. It is cheap enough (O(n x K) with a -# bitmap per shard) to run more than once, which is what lets the deadline -# check below see a real partition rather than an average. -partition <- function(k) { - assignment <- integer(n) - load <- rep(setup_minutes + length(dev_closure) * penalty, k) - check_load <- numeric(k) - have <- matrix(FALSE, nrow = length(universe), ncol = k) - have[match(dev_closure, universe), ] <- TRUE - - place <- function(i, s) { - p <- ord[[i]] - fresh <- sum(!have[dep_idx[[p]], s]) - assignment[[p]] <<- s - check_load[[s]] <<- check_load[[s]] + weight[[p]] - load[[s]] <<- load[[s]] + weight[[p]] + fresh * penalty - have[dep_idx[[p]], s] <<- TRUE - } - - # Phase 1: the K heaviest packages, dealt round-robin. - for (i in seq_len(min(k, n))) { - place(i, i) - } - - # Phase 2: everything else goes where it costs least, dependency reuse folded - # into the price. - if (n > k) { - for (i in seq(k + 1L, n)) { - p <- ord[[i]] - fresh <- colSums(!have[dep_idx[[p]], , drop = FALSE]) - score <- load + weight[[p]] + fresh * penalty - place(i, which.min(score)) - } - } - - list(assignment = assignment, load = load, check_load = check_load) -} - -# `capacity` bounds the *checks* a shard may hold; the shard also spends its -# setup and its installs inside the same deadline, and only a real partition -# says how much that is -- the install union of a shard is not a per-package -# constant. So the estimate is checked against the deadline here, and a shard -# count that cannot hold it grows by whole waves until it can. -fit <- partition(k) -while (max(fit$load) > deadline_minutes && k < max_k) { - grown <- min(as.integer(k + lanes), max_k) - inform(sprintf( - "Heaviest shard estimated at ~%.0f min, past the %.0f min deadline; growing to %d shard(s)", - max(fit$load), - deadline_minutes, - grown - )) - k <- grown - fit <- partition(k) -} -assignment <- fit$assignment -load <- fit$load -check_load <- fit$check_load -waves <- as.integer(ceiling(k / lanes)) - -# Out of room: the matrix limit (or the package count) caps the shards below -# what the work needs, so every shard would run into its deadline and defer. -# That is a plan worth refusing -- a run started this way spends hours to -# report half its packages as deferred, and says so only at the end. -# -# How many runs it takes instead: splitting into G parts divides a shard's -# check load by G, but not its setup or its installs, so the question is how -# much of the deadline is left for checks once those are paid. -plan_too_big <- function() { - worst <- which.max(load) - overhead <- load[[worst]] - check_load[[worst]] - headroom <- deadline_minutes - overhead - # A package is never split across shards, so one package heavier than the - # room a shard has is a wall that no number of parts gets around. Say which - # package, and stop recommending a split that cannot work. - giants <- names(weight)[weight > headroom] - parts <- if (headroom <= 0 || length(giants) > 0) { - NA_integer_ - } else { - max(2L, as.integer(ceiling(check_load[[worst]] / headroom))) - } - inform(sprintf( - "Too big for one run: %d shard(s) is the limit, and the heaviest would be ~%.0f min against a %.0f min deadline", - k, - load[[worst]], - deadline_minutes - )) - append_summary(c( - "## revdep2 plan", - "", - "**Too big for one run — nothing was started.**", - "", - sprintf( - "%d packages need ~%.0f check minutes. At most %d shard%s can be planned (%s), which puts the heaviest at ~%.0f min: ~%.0f min of checks on top of ~%.0f min of setup and installs, against the %.0f min a shard has before its deadline starts deferring packages.", - n, - total_check, - max_k, - if (max_k == 1) "" else "s", - if (max_k < as.integer(max_shards)) { - sprintf("one per package, and there are only %d", n) - } else { - sprintf("`max-shards`, itself capped at 250 by the matrix limit") - }, - load[[worst]], - check_load[[worst]], - overhead, - deadline_minutes - ), - "", - if (headroom <= 0) { - c( - sprintf( - "Setup and installs alone (~%.0f min) already exceed the deadline, so splitting the packages will not help: raise `REVDEP2_DEADLINE_MINUTES` (and the job's `timeout-minutes`, up to GitHub's 6 h ceiling) first.", - overhead - ), - "" - ) - } else if (length(giants) > 0) { - # Naming them matters: this is the one case where the operator has to - # decide something (wait longer, or check less), and the decision is - # about these packages specifically. - c( - sprintf( - "%s alone %s more than the ~%.0f min a shard has left for checks, and a package is never split across shards — so no `part` split helps here.", - paste0("`", paste(utils::head(sort(giants), 5), collapse = "`, `"), "`"), - if (length(giants) == 1) "needs" else "need", - headroom - ), - "", - sprintf( - "Raise `REVDEP2_DEADLINE_MINUTES` (now %.0f) and the shard job's `timeout-minutes` (now 350, GitHub's ceiling is 6 h), or leave %s out of the run with an explicit `packages` list.", - deadline_minutes, - if (length(giants) == 1) "it" else "them" - ), - "" - ) - } else { - c( - sprintf("Split it into %d runs, each an independent report:", parts), - "", - "```sh", - paste0( - sprintf( - "gh workflow run revdep2.yaml -f part=%d/%d", - seq_len(parts), - parts - ), - collapse = "\n" - ), - "```", - "", - paste( - "The parts are cut from the same weight-ordered list, dealt round", - "robin, so they are of similar size and together cover everything —", - "and each part re-plans itself, so a part that is still too big says", - "so in turn. They share baselines and prebuilt libraries through the", - "usual artifacts, so the later parts start warmer than the first." - ), - "" - ) - }, - "Or keep it in one run by making the shards fit:", - "", - sprintf( - "* `max-parallel` above %d does not change this — the limit is how many shards may exist (250), not how many run at once.", - max_parallel - ), - sprintf( - "* raise `shard-capacity-minutes` (now %.0f) only together with `REVDEP2_DEADLINE_MINUTES` (now %.0f) and the shard job's `timeout-minutes` (350): the deadline is what a shard actually has.", - capacity, - deadline_minutes - ), - "* pass an explicit `packages` list, or a smaller `depth`, to check less.", - "" - )) - quit(save = "no", status = 1) -} -if (max(load) > deadline_minutes) { - plan_too_big() -} - -inform(sprintf( - "%d shard(s) in %d wave(s); heaviest ~%.0f min (checks ~%.0f, deadline %.0f)", - k, - waves, - max(load), - max(check_load), - deadline_minutes -)) - -# A plan can fit the matrix and still be a bad idea: `which: most` at `depth: -# 2` is 3419 packages and about 22 hours of waves here, which is a run nobody -# is watching by the end and a day of artifacts riding on one preflight. It is -# a legitimate thing to ask for, so this warns rather than refuses -- but it -# warns where the dispatcher will see it, not only in the wave line. -wall_minutes <- waves * max(load) -long_run_hours <- env_num("REVDEP2_LONG_RUN_HOURS", 12) -long_run <- wall_minutes > long_run_hours * 60 - -# ------------------------------------------------------------------ output --- - -shard_members <- lapply(seq_len(k), function(s) { - members <- packages[assignment == s] - members[order(-weight[members])] -}) -shard_install <- lapply(shard_members, function(members) { - sort(unique(c(dev_closure, unlist(closure[members], use.names = FALSE)))) -}) - -# What the preflight installs, which is not the whole universe. -# -# A shard unpacks the preflight's library and builds only what is missing, so -# preflighting a package is worth it exactly when more than one shard needs -# it: build it once centrally instead of once per shard. A package only one -# shard needs is built once either way -- the preflight merely moves that -# build off the shard, where it runs 20-wide, and onto the critical path, -# where it runs alone. -# -# On the 3434-revdep set, planned into 60 shards, that is 1639 of 4406 -# packages -- 37% of the preflight's work for no saving at all. Dropping them -# is free in the strict sense: the total number of installs across the run is -# identical (4406 either way), and so is the number of downloads, since a -# package one shard needs is fetched once whoever fetches it. -# -# Going further is a real trade rather than a freebie. A threshold of 3 sheds -# another 659 packages but has each of them built twice instead of once, so -# the run does 5065 installs instead of 4406. Worth having as a knob for a -# preflight under time pressure, not worth defaulting to. -# -# The threshold is capped at the shard count: with one shard every package is -# needed by every shard, and the preflight installing nothing would leave the -# next run without a donor library. -preflight_min_shards <- max( - 1L, - min(as.integer(env_num("REVDEP2_PREFLIGHT_MIN_SHARDS", 2)), k) -) -shards_needing <- table(unlist(shard_install, use.names = FALSE)) -preflight_union <- sort(names(shards_needing)[ - shards_needing >= preflight_min_shards -]) -inform(sprintf( - "Preflight installs %d of the %d packages in the universe: those at least %d shard(s) need (%d are needed by one shard, and stay with it)", - length(preflight_union), - length(universe), - preflight_min_shards, - sum(shards_needing < preflight_min_shards) -)) - -shard_list <- lapply(seq_len(k), function(s) { - members <- shard_members[[s]] - install <- shard_install[[s]] - list( - index = s, - estimate_minutes = round(load[[s]], 1), - check_minutes = round(check_load[[s]], 1), - install_packages = length(install), - install = as.list(install), - packages = lapply(members, function(p) { - list( - name = p, - version = unname(their_version[[p]]), - level = if (p %in% names(level_of)) unname(level_of[[p]]) else 0L, - weight_minutes = round(unname(weight[[p]]), 2), - t_total = unname(t_total[[p]]), - check_seconds = round(unname(check_seconds[[p]])), - timing_source = timing_source[[match(p, packages)]], - dep_fingerprint = unname(fingerprint[[p]]), - baseline = unname(reuse[[p]]) - ) - }) - ) -}) - -# The dispatched ref and the checked-out tree differ when the `ref` input -# names another branch or SHA; the tree is what is being tested. -head_sha <- tryCatch( - system2("git", c("rev-parse", "HEAD"), stdout = TRUE, stderr = NULL)[[1]], - error = function(e) "" -) -if (!nzchar(head_sha)) { - head_sha <- env_chr("GITHUB_SHA") -} - -plan <- list( - package = package, - dev_version = dev_version, - cran_version = cran_version, - r_version = r_version, - sha = head_sha, - ref = env_chr("GITHUB_REF_NAME"), - which = which_input, - depth = depth_raw, - levels = as.list(level_counts), - selection = selection, - part = part, - generated_at = now_utc(), - timing_flavor = timing_flavor, - retry_of = if (nzchar(retry_run)) run_id_chr(retry_run) else "0", - baseline = list( - run_id = baseline_run, - max_age_days = baseline_max_age, - reused = sum(reuse), - fresh = sum(!reuse) - ), - prebuilt = list( - max_runs = max_prebuilt_runs, - max_age_days = prebuilt_max_age, - runs_scanned = history$scanned, - covered = prebuilt_covered, - missing = length(history$missing), - runs = prebuilt - ), - calibration = list( - runs = if (nzchar(local_measured)) { - as.list(local_measured) - } else { - as.list(history$timings_runs) - }, - max_runs = max_measured_runs, - max_age_days = measured_max_age, - packages_measured = sum(packages %in% seen), - check_scale = round(check_scale, 3), - setup_minutes = round(setup_minutes, 2), - install_seconds = round(install_seconds, 2) - ), - params = list( - shard_budget_minutes = budget, - shard_capacity_minutes = capacity, - max_shards = max_shards, - max_parallel = max_parallel, - lanes = lanes, - waves = waves, - install_seconds_per_package = install_seconds, - setup_minutes = setup_minutes, - package_overhead_minutes = overhead_minutes - ), - totals = list( - revdeps = length(revdeps), - packages = n, - check_minutes = round(total_check, 1), - estimate_minutes = round(sum(load), 1), - wave_minutes = round(waves * max(load), 1), - universe = length(universe), - install_union = length(preflight_union), - preflight_min_shards = preflight_min_shards - ), - dropped_unknown = as.list(dropped), - # The preflight's list, not the universe: the shards install their own - # unions, and what only one of them needs is left to it. - install_union = as.list(preflight_union), - dev_closure = as.list(dev_closure), - shards = shard_list -) -write_json(plan, out_path) -plan_hash <- unname(tools::md5sum(out_path)) -inform("Plan written to ", out_path) - -parallel <- max(1L, min(as.integer(max_parallel), k)) -matrix <- list( - include = lapply(shard_list, function(s) { - list( - shard = s$index, - label = sprintf( - "%d pkgs, ~%.0f min", - length(s$packages), - s$estimate_minutes - ) - ) - }) -) - -set_output("matrix", jsonlite::toJSON(matrix, auto_unbox = TRUE)) -set_output("shards", as.character(k)) -set_output("packages", as.character(n)) -set_output("max_parallel", as.character(parallel)) -set_output("baseline_run", baseline_run) -set_output("plan_hash", plan_hash) - -# ------------------------------------------------------------------ summary -- - -top <- function(s) { - names <- vapply(s$packages, function(p) p$name, character(1)) - paste(utils::head(names, 3), collapse = ", ") -} -summary_df <- data.frame( - Shard = vapply(shard_list, function(s) s$index, integer(1)), - Packages = vapply(shard_list, function(s) length(s$packages), integer(1)), - `Check est.` = sprintf( - "~%.0f min", - vapply(shard_list, function(s) s$check_minutes, numeric(1)) - ), - `Total est.` = sprintf( - "~%.0f min", - vapply(shard_list, function(s) s$estimate_minutes, numeric(1)) - ), - Installs = vapply(shard_list, function(s) s$install_packages, integer(1)), - Heaviest = vapply(shard_list, top, character(1)), - check.names = FALSE -) -append_summary(c( - "## revdep2 plan", - "", - if (env_flag("REVDEP2_DRY_RUN")) c("**Dry run: planning only, no checks started.**", ""), - if (long_run) { - c( - sprintf( - "> **This plan is about %.0f hours of wall clock** — %d shards, %d waves of %d. It fits, and it will run; the note is that %s.", - wall_minutes / 60, - k, - waves, - lanes, - if (length(measured_runs) == 0) { - "nothing is measured yet, so it is priced on CRAN's times, which have run about twice the local cost \u2014 the same plan calibrated is roughly half this" - } else { - "a run this long rides on one preflight and a day of artifacts" - } - ), - sprintf( - "> A narrower `which` or `depth` is the dial; `part=i/%d` splits it into runs that each report on their own, without making the total any shorter.", - max(2L, as.integer(ceiling(wall_minutes / (long_run_hours * 60)))) - ), - "" - ) - }, - "| | |", - "| --- | --- |", - sprintf("| Package | `%s` %s (CRAN: %s) |", package, dev_version, cran_version), - sprintf("| Selection | %s |", selection_md %||% selection), - # The dispatch inputs that decide how wide the net was, echoed verbatim. - # GitHub does not show a run which inputs it was given, and a plan that says - # only "all" and a package count leaves "was that depth 2 or the whole - # closure?" unanswerable from the run page -- which is a question worth - # asking, since the two differ by hours. - sprintf( - "| Reverse dependencies | `which: %s`, `depth: %s`%s%s |", - which_input, - depth_raw, - if (identical(depth, Inf)) " (the full transitive closure)" else "", - if (length(level_counts) > 0) { - paste0( - " — ", - paste0("level ", names(level_counts), ": ", level_counts, collapse = ", ") - ) - } else { - "" - } - ), - sprintf("| Packages to check | %d (of %d revdeps) |", n, length(revdeps)), - sprintf( - "| Baseline | %s |", - if (length(baseline_manifest) > 0) { - sprintf( - "%s: %d reused, %d fresh", - if (has_run(baseline_run)) { - paste("run", run_link(baseline_run)) - } else { - "local" - }, - sum(reuse), - sum(!reuse) - ) - } else { - "none" - } - ), - sprintf( - "| Prebuilt packages | %s |", - if (length(prebuilt) > 0) { - sprintf( - "%d of %d from run%s %s", - prebuilt_covered, - length(universe), - if (length(prebuilt) > 1) "s" else "", - paste( - vapply(prebuilt, function(d) run_link(d$run_id), character(1)), - collapse = ", " - ) - ) - } else { - "none" - } - ), - sprintf( - "| Preflight installs | %d of %d (what at least %d shard%s need%s; the other %d stay with the one shard that needs them) |", - length(preflight_union), - length(universe), - preflight_min_shards, - if (preflight_min_shards == 1) "" else "s", - if (preflight_min_shards == 1) "s" else "", - length(universe) - length(preflight_union) - ), - sprintf( - "| Cost model | %s |", - if (length(measured_runs) > 0) { - sprintf( - "measured by %s: checks at %.2f× their CRAN time, %.1f min setup per shard, %.1f s per dependency installed (%d of %d packages timed here before)", - if (nzchar(local_measured)) { - sprintf("`%s`", local_measured) - } else { - sprintf( - "run%s %s", - if (length(history$timings_runs) > 1) "s" else "", - paste( - vapply(history$timings_runs, run_link, character(1)), - collapse = ", " - ) - ) - }, - check_scale, - setup_minutes, - install_seconds, - sum(packages %in% seen), - n - ) - } else { - "uncalibrated: CRAN check times as they are, default shard costs" - } - ), - sprintf( - "| Shards | %d in %d wave(s) of %d (budget %.0f min, capacity %.0f min per shard) |", - k, - waves, - parallel, - budget, - capacity - ), - sprintf( - "| Estimated wall clock | ~%.0f min (%d wave(s) of ~%.0f min) |", - waves * max(load), - waves, - max(load) - ), - sprintf("| Estimated runner time | ~%.0f min |", sum(load)), - "", - # More than one wave means the run is bound by how many jobs may run at - # once, and that ceiling is the account's, not this workflow's: past it - # GitHub queues jobs no matter what `max-parallel` says, and a plan told it - # has more lanes than it does just cuts more shards, each paying its own - # setup while it waits. So state the fact and the condition, not a knob to - # turn. - if (waves > 1) { - c( - sprintf( - "These %d shards run %d at a time, so %d waves, ~%.0f min. Raising `max-parallel` shortens that only if the account can really run more jobs at once (GitHub queues past its own concurrency limit either way); a `part` split does not shorten it at all, since the parts compete for the same lanes.", - k, - lanes, - waves, - waves * max(load) - ), - "" - ) - }, - md_table(summary_df) -)) diff --git a/.github/workflows/revdep2/preflight.R b/.github/workflows/revdep2/preflight.R deleted file mode 100644 index 7be71c6..0000000 --- a/.github/workflows/revdep2/preflight.R +++ /dev/null @@ -1,502 +0,0 @@ -# Prove the dependency world installs before any shard spends a minute on -# checks: install the union of every dependency any revdep needs into a -# scratch library -- which downloads every binary exactly once into the pak -# cache the workflow then saves for the shards -- and load-test each installed -# package. Broken or uninstallable dependencies surface here, in depfail.json -# and the job summary. -# -# A dependency failure is a report, not a stop: shards attempt their own -# subset regardless (their repository snapshot may succeed where this one -# failed), and a revdep whose dependencies genuinely cannot be installed fails -# its own check with an install log, which is the result a report can work -# with. -# -# The library this job ends up with is also the run's contribution to the next -# one: it is packed into the revdep2-lib artifact, which later runs unpack -# instead of building the same packages again (see util.R). -# -# Environment variables: -# PLAN - plan.json from plan.R (default: plan.json) -# OUT_DIR - where depfail.json lands (default: preflight) -# LIB_OUT - where library.tar and lib.json land; empty skips packing -# LIB_INDEX_OUT - where a copy of lib.json alone lands, for the small -# artifact a later plan reads without the tar -# -# Nothing here waits without a clock: REVDEP2_INSTALL_TIMEOUT_MINUTES bounds -# one pak call, REVDEP2_LOAD_TIMEOUT_MINUTES one load-test batch, and -# REVDEP2_INSTALL_DEADLINE_MINUTES the installs together -- see the README's -# "Nothing waits for ever". - -script_dir <- dirname(sub( - "--file=", - "", - grep("^--file=", commandArgs(), value = TRUE) -)) -source(file.path(script_dir, "util.R")) - -plan <- read_json(env_chr("PLAN", "plan.json")) -out_dir <- env_chr("OUT_DIR", "preflight") -dir.create(out_dir, recursive = TRUE, showWarnings = FALSE) -install_union <- unlist(plan$install_union, use.names = FALSE) - -lib <- file.path(env_chr("RUNNER_TEMP", tempdir()), "revdep2-preflight-lib") -dir.create(lib, recursive = TRUE, showWarnings = FALSE) -failures <- list() - -# What earlier runs already built, unpacked before pak sees the library. pak -# still resolves the whole union afterwards -- CRAN moves between runs, and a -# package whose version changed has to be built after all -- but everything -# unchanged is now already there, and is skipped. -promised <- unique(unlist( - lapply(plan$prebuilt$runs %||% list(), function(d) { - unlist(d$packages, use.names = FALSE) - }), - use.names = FALSE -)) -restored <- restore_prebuilt(plan, lib, install_union) -inform( - "Preflight: ", - length(restored), - " of the ", - length(intersect(promised, install_union)), - " package(s) the plan expected were restored from earlier runs" -) - -# With a restored library, `upgrade = FALSE` would freeze whatever version the -# donor happened to hold; the plan's dependency fingerprints are computed from -# CRAN *now*, so the library has to follow CRAN now. -upgrade <- length(restored) > 0 - -# This install is the whole job, and the place it has died: handed the whole -# universe at once, pak resolves every one of those refs before it installs -# any of them, and the resolution of a few thousand is where a run that is -# killed rather than failed gets killed. So it goes in dependency order, four -# hundred at a time (see install_chunks() in util.R), which keeps every -# resolution well clear of the size that killed it and turns a fatal ten -# minutes of silence into a chunk counter -- the workflow's resource sampler -# supplies the other half of that picture, a memory curve on the same clock. -chunk_size <- env_num("REVDEP2_INSTALL_CHUNK", 400) -# Past this, no further chunk is started. The job's own `timeout-minutes` is -# 300 and cancels everything; this stops earlier and on purpose, so that the -# packages that did install are still load-tested, packed and published -# instead of dying with the job. -install_deadline <- Sys.time() + - env_num("REVDEP2_INSTALL_DEADLINE_MINUTES", 210) * 60 -# And a deadline for the whole job, because stopping the *installs* early only -# helps if what follows them is bounded too. After `install_deadline` come the -# sysreqs survey, some seventy load batches at up to `REVDEP2_LOAD_TIMEOUT_ -# MINUTES` each, a per-package retry of every failure, and a rebuild loop of -# up to `REVDEP2_INSTALL_TIMEOUT_MINUTES` per stale binary -- whose worst case -# is far past the job's own `timeout-minutes: 300`. Reaching that means the -# library is never packed and `revdep2-lib` is never uploaded, so every shard -# rebuilds from scratch: the one outcome this job exists to prevent. -job_deadline <- Sys.time() + - env_num("REVDEP2_JOB_DEADLINE_MINUTES", 270) * 60 -out_of_time <- function(what) { - if (Sys.time() <= job_deadline) { - return(FALSE) - } - inform( - "Past the job deadline; ", - what, - " stops here so the library is packed" - ) - TRUE -} -chunks <- install_chunks(install_union, cran_db(), chunk_size) -inform( - "Preflight: installing ", - length(install_union), - " packages (", - length(missing_from(lib, install_union)), - " not in the library yet) in ", - length(chunks), - " chunk(s) of at most ", - chunk_size, - ", dependencies first; upgrade = ", - upgrade -) -# Before the first install, not after the first failure: a poisoned metadata -# database is inherited through the pak cache the workflow restores, so the -# job can start with one. Asking pak what it can see costs seconds and is the -# difference between one bad job and a run where every shard installs nothing. -metadata <- ensure_metadata("Preflight") -if (identical(metadata, "broken")) { - stop( - "pak cannot see the packages that must exist, before or after rebuilding ", - "its metadata database. Installing anything now would fail package by ", - "package for hours and publish a cache that fails every shard the same ", - "way.", - call. = FALSE - ) -} - -# What the resource sampler calls the samples it is taking. It runs for the -# whole job, so a label fixed when it started would say `installing` through the -# load test and the packing as well -- which is what it used to do. -phase_file <- env_chr("RESOURCE_PHASE_FILE") -phase <- function(name) { - if (nzchar(phase_file)) { - writeLines(name, phase_file) - } - invisible(name) -} - -phase("installing") -install_started <- Sys.time() -installed_ok <- install_in_chunks( - chunks, - lib, - upgrade, - "Preflight", - deadline = install_deadline -) -inform(sprintf( - "Preflight: the install %s after %.1f min; %d of %d packages are in the library", - if (installed_ok) "finished" else "failed", - as.numeric(difftime(Sys.time(), install_started, units = "mins")), - length(install_union) - length(missing_from(lib, install_union)), - length(install_union) -)) -if (!installed_ok) { - # One bad package must not hide the state of the other thousand: retry each - # missing package on its own and record exactly which ones will not install. - # Bounded twice over -- one package may not hang the retry, and the retry as - # a whole may not eat the minutes the load test and the library packing still - # need. What the deadline cuts off is named rather than reported as failing. - retry <- missing_from(lib, install_union) - inform("Preflight: retrying ", length(retry), " package(s) one at a time") - for (i in seq_along(retry)) { - if (Sys.time() > install_deadline) { - inform(sprintf( - "Preflight: the install deadline passed; %d of %d package(s) not retried", - length(retry) - i + 1L, - length(retry) - )) - break - } - run <- pak_install( - retry[[i]], - lib = lib, - upgrade = upgrade, - timeout_seconds = install_timeout_seconds(), - label = paste("Preflight: installing", retry[[i]]) - ) - if (!run$ok) { - failures[[length(failures) + 1]] <- list( - package = retry[[i]], - phase = "install", - message = run$message - ) - } - } -} - -# Before the load test, because a restored package whose system library is -# absent fails to load for a reason that has nothing to do with the package: -# without this it would be judged stale and rebuilt from source, and fail -# again the same way. -phase("surveying system requirements") -ensure_sysreqs(lib, "Preflight") - -# Load every installed dependency, in chunks small enough to stay clear of the -# DLL limit; a failing chunk is retried one package at a time so a single bad -# namespace names itself. -installed <- intersect(install_union, rownames(utils::installed.packages(lib))) -phase("load-testing") -inform("Preflight: loading ", length(installed), " packages") - -# Bounded, because `loadNamespace()` is not a thing that necessarily returns: -# a package whose .onLoad waits on a lock, a port or a display hangs the child -# for ever, and this used to wait for it with no clock -- the same unbounded -# wait that cost run 31276552027 its preflight, one call further on. A batch -# that runs out of time is retried package by package, which is already how a -# failing batch names its culprit; a single package that then times out is a -# load failure like any other, with "timed out" as its reason. -load_timeout_sec <- env_num("REVDEP2_LOAD_TIMEOUT_MINUTES", 10) * 60 -# One session per package, several at a time. The runner has four cores and -# loading is mostly I/O and dynamic linking, so it parallelises well. -load_jobs <- max(1, env_num("REVDEP2_LOAD_JOBS", parallel::detectCores())) -load_sweep_sec <- env_num("REVDEP2_LOAD_SWEEP_MINUTES", 60) * 60 -load_batch <- function(pkgs) { - script <- tempfile(fileext = ".R") - writeLines( - c( - sprintf(".libPaths(c(%s, .libPaths()))", deparse(lib)), - "for (p in commandArgs(trailingOnly = TRUE)) {", - " loadNamespace(p)", - " writeLines(paste0('LOADED ', p))", - "}" - ), - script - ) - args <- c("--vanilla", script, pkgs) - if (requireNamespace("processx", quietly = TRUE)) { - # processx runs the command directly rather than through a shell, so the - # arguments need no quoting of their own. - run <- processx::run( - "Rscript", - args, - timeout = load_timeout_sec, - error_on_status = FALSE, - stderr_to_stdout = TRUE - ) - out <- strsplit(run$stdout %||% "", "\n", fixed = TRUE)[[1]] - timed_out <- isTRUE(run$timeout) - } else { - out <- suppressWarnings(system2( - "Rscript", - # Quoted: system2() quotes the command, but not the arguments. - shQuote(args), - stdout = TRUE, - stderr = TRUE - )) - timed_out <- FALSE - } - loaded <- sub("^LOADED ", "", grep("^LOADED ", out, value = TRUE)) - list(failed = setdiff(pkgs, loaded), log = out, timed_out = timed_out) -} -# Which packages actually have to be loaded. -# -# Loading a namespace loads everything it imports, transitively -- so loading -# the packages nothing else in the set depends on covers the whole set. In a -# DAG every other package is reachable from at least one of those roots, by -# following dependents upwards until there are none. For a universe of a few -# thousand packages the roots are a few hundred, so this is the same coverage -# for a fraction of the sessions. -# -# The saving is real but it is not the main point. One session per package -# means one clock per package: a package whose `.onLoad` blocks used to spend -# a batch's whole ten minutes and take 39 innocent packages with it, and the -# batch then had to be re-run package by package to find out which one it was. -# And independent sessions run at once, which is what the runner's other three -# cores are for. -load_roots <- function(pkgs) { - db <- cran_db() - known <- intersect(pkgs, rownames(db)) - if (length(known) == 0) { - return(pkgs) - } - deps <- tools::package_dependencies( - known, - db = db, - which = "strong", - recursive = TRUE - ) - depended_on <- unique(unlist(deps, use.names = FALSE)) - roots <- setdiff(known, depended_on) - # Anything the database cannot speak for is tested in its own right rather - # than assumed to be covered by something else. - c(roots, setdiff(pkgs, known)) -} - -load_failures <- list() -roots <- load_roots(installed) -inform(sprintf( - "Preflight: load-testing %d of %d installed package(s) -- the ones nothing else needs, which pull the rest in -- %d at a time, %.0f min each", - length(roots), - length(installed), - load_jobs, - load_timeout_sec / 60 -)) -if (!out_of_time("the load test") && length(roots) > 0) { - list_file <- file.path(tempdir(), "load-roots.txt") - writeLines(roots, list_file) - run <- run_with_timeout( - function(script, args) { - # stdout captured, stderr inherited: the script writes its verdicts to - # both, and the stderr copy is what reaches the job log as the sweep - # runs rather than half an hour later. - system2(script, args, stdout = TRUE, stderr = "") - }, - list( - script = file.path(script_dir, "load-test.sh"), - args = shQuote(c( - list_file, - lib, - format(round(load_timeout_sec), scientific = FALSE), - format(load_jobs) - )) - ), - # The whole sweep, bounded independently of the per-package clocks: with - # `jobs` in parallel the worst case is roughly `roots / jobs` timeouts, and - # this is the backstop for the case where that is still too long. - timeout_seconds = min( - load_sweep_sec, - max(60, as.numeric(difftime(job_deadline, Sys.time(), units = "secs"))) - ), - label = "load test" - ) - out <- if (is.character(run$value)) run$value else character() - for (line in grep("^FAIL ", out, value = TRUE)) { - parts <- strsplit(line, " ", fixed = TRUE)[[1]] - load_failures[[parts[[2]]]] <- if (identical(parts[[3]], "timeout")) { - sprintf("loading timed out after %.0f min", load_timeout_sec / 60) - } else { - "loading failed" - } - } - - # Every package that was tested, and what it cost, folded away. - # - # Without this the step says "load-testing 498 packages" and then nothing at - # all until the summary -- and a package that loads *slowly* has nowhere to - # show up, though every check of anything downstream of it pays that cost - # again. 498 lines is a lot to scroll past, so they go in a collapsed group - # and the interesting ones are repeated outside it. - timed <- do.call( - rbind, - lapply( - strsplit(grep("^(OK|FAIL) ", out, value = TRUE), " ", fixed = TRUE), - function(p) { - data.frame( - package = p[[2]], - seconds = suppressWarnings(as.numeric(utils::tail(p, 1))), - ok = identical(p[[1]], "OK") - ) - } - ) - ) - if (!is.null(timed) && nrow(timed) > 0) { - timed <- timed[order(-timed$seconds), ] - print_group( - sprintf("Load test: %d package(s), slowest first", nrow(timed)), - sprintf( - "%6.0fs %-30s %s", - timed$seconds, - timed$package, - ifelse(timed$ok, "", "FAILED") - ) - ) - slow <- utils::head(timed[timed$ok, ], 5) - inform(sprintf( - "Load test: %d ok, %d failed, %s of CPU across %d job(s); slowest: %s", - sum(timed$ok), - sum(!timed$ok), - format_duration(sum(timed$seconds)), - load_jobs, - paste( - sprintf("%s (%.0fs)", slow$package, slow$seconds), - collapse = ", " - ) - )) - } - if (!run$ok) { - inform("The load test did not finish: ", run$message) - } -} - -# What a failure means is worth a second look, so the ones that failed are -# re-run alone with their output kept. There are few of them by construction. -for (p in names(load_failures)) { - if (out_of_time("the load test")) { - break - } - single <- load_batch(p) - if (length(single$failed) == 0) { - load_failures[[p]] <- NULL - next - } - if (!isTRUE(single$timed_out)) { - load_failures[[p]] <- paste( - utils::tail(sanitize_log(single$log), 20), - collapse = "\n" - ) - } -} - -# A restored package that will not load is a stale binary, not a broken -# package: the runner image moved under it. Throw it away, let pak build it -# from source, and judge it on the second attempt -- this is the one failure -# mode reuse introduces, and it is cheap to undo. -stale <- intersect(names(load_failures), restored) -if (length(stale) > 0) { - inform( - "Preflight: rebuilding ", - length(stale), - " restored package(s) that would not load" - ) - unlink(file.path(lib, stale), recursive = TRUE) - for (p in stale) { - run <- pak_install( - p, - lib = lib, - upgrade = FALSE, - timeout_seconds = install_timeout_seconds(), - label = paste("Preflight: rebuilding", p) - ) - if (!run$ok) { - inform("Could not reinstall ", p, ": ", run$message) - } - } - for (p in stale) { - retried <- load_batch(p) - if (length(retried$failed) == 0) { - load_failures[[p]] <- NULL - } else { - load_failures[[p]] <- paste( - utils::tail(sanitize_log(retried$log), 20), - collapse = "\n" - ) - } - } -} -for (p in names(load_failures)) { - failures[[length(failures) + 1]] <- list( - package = p, - phase = "load", - message = load_failures[[p]] - ) -} - -write_json(failures, file.path(out_dir, "depfail.json")) - -# ------------------------------------------------------------------ library -- - -phase("packing the library") -lib_out <- env_chr("LIB_OUT") -index_out <- env_chr("LIB_INDEX_OUT") -packed <- character() -if (nzchar(lib_out)) { - packed <- pack_library( - lib, - lib_out, - if (nzchar(index_out)) index_out else NULL - ) - inform("Preflight: published ", length(packed), " package(s) for later runs") -} - -append_summary(c( - "## revdep2 preflight", - "", - # The count is not the whole universe, and saying so here saves the reader a - # trip to the plan: what only one shard needs is installed by that shard. - sprintf( - "Installed and loaded %d of the run's %d dependencies -- the ones more than one shard needs. %d could not be installed or loaded.", - length(install_union), - plan$totals$universe %||% length(install_union), - length(failures) - ), - "", - sprintf( - "%d package(s) came prebuilt from earlier runs%s; %d are published for the next one.", - length(restored), - if (length(stale) > 0) sprintf(" (%d rebuilt after failing to load)", length(stale)) else "", - length(packed) - ), - "" -)) -if (length(failures) > 0) { - df <- data.frame( - Package = vapply(failures, function(f) f$package, character(1)), - Phase = vapply(failures, function(f) f$phase, character(1)) - ) - append_summary(md_table(df)) - for (f in failures) { - append_summary(md_details( - sprintf("%s — %s failure", f$package, f$phase), - strsplit(f$message, "\n")[[1]] - )) - } - inform(length(failures), " dependencies failed preflight; see depfail.json") -} diff --git a/.github/workflows/revdep2/shard.R b/.github/workflows/revdep2/shard.R deleted file mode 100644 index 291e341..0000000 --- a/.github/workflows/revdep2/shard.R +++ /dev/null @@ -1,1403 +0,0 @@ -# Check one shard of a revdep2 plan: many reverse dependencies, one job, one -# shared library. -# -# It runs in two phases, `install` and `check`, so that the workflow can put -# each in its own step and Actions can time them separately; `PHASE=all` runs -# both in one go, which is what a local invocation wants. -# -# The shard installs the union of its packages' dependencies once, then checks -# each of its packages against both versions of the package under test at the -# same time: two `R CMD check` runs side by side, against library stacks that -# differ in exactly that one package. Both halves always run -- a reusable -# baseline used to stand in for the old one, and comparing against another -# run's machine and CRAN snapshot is what made 76 of run 31879790285's 78 -# `newly_broken` verdicts false. The two results are compared per package, -# revdepcheck-style. -# -# Failure is data here, never a job failure: a package that breaks, times out, -# or cannot even install its dependencies gets a manifest entry saying so, and -# the walk continues. The job goes red only when the driver itself is broken. -# -# The shard stops starting new checks when its deadline says the next one will -# not finish, and records the rest as deferred; a later run started with -# `retry-run` picks exactly those up. Results that exist by then -- including -# an old-version result whose new-version counterpart was cut off -- are still -# uploaded, so nothing decided is lost to the deadline. -# -# Before installing anything, the shard unpacks prebuilt dependencies (see -# util.R): this run's preflight library, and then the earlier runs the plan -# picked. pak only has to build what neither of them had. -# -# What each phase costs is recorded in timing.json next to the results: the -# collector folds it into the run's timings artifact, and the next plan sizes -# its shards from what this one measured rather than from CRAN's numbers and a -# guess. -# -# Environment variables: -# SHARD - shard index from plan.json (required) -# PLAN - plan file (default: plan.json) -# PKG_DIR - the revdep2-pkg artifact: meta.json, bin/ (required) -# LIB_DIR - the revdep2-lib artifact of *this* run: the -# preflight's library; may be missing or empty -# BASELINE_DIR - the revdep2-baseline artifact of the donor run; -# may be missing or empty, then everything is fresh -# OUT_DIR - results directory, uploaded as the shard artifact -# (default: results) -# TIMEOUT_FACTOR - per-check timeout as a multiple of the package's -# CRAN check time (default: 1.5) -# TIMEOUT_MIN_MINUTES - floor for that timeout; CRAN's machines are not -# these runners (default: 20 in the workflow) -# DEADLINE_MINUTES - stop starting new checks past this (default: 300) -# PHASE - "install", "check", or "all" (default): which half -# of the shard this invocation runs - -script_dir <- dirname(sub( - "--file=", - "", - grep("^--file=", commandArgs(), value = TRUE) -)) -source(file.path(script_dir, "util.R")) - -script_started <- Sys.time() -elapsed <- function(from) { - round(as.numeric(difftime(Sys.time(), from, units = "secs")), 1) -} - -shard_index <- as.integer(env_chr("SHARD")) -stopifnot(!is.na(shard_index)) -plan <- read_json(env_chr("PLAN", "plan.json")) -pkg_dir <- env_chr("PKG_DIR", "pkg") -baseline_dir <- env_chr("BASELINE_DIR", "baseline") -out_dir <- env_chr("OUT_DIR", "results") -timeout_factor <- env_num("TIMEOUT_FACTOR", 1.5) -timeout_min_sec <- env_num("TIMEOUT_MIN_MINUTES", 10) * 60 -deadline <- Sys.time() + env_num("DEADLINE_MINUTES", 300) * 60 - -mine <- Filter(function(s) s$index == shard_index, plan$shards) -if (length(mine) == 0) { - stop( - "Plan has no shard ", - shard_index, - " (it has ", - length(plan$shards), - "); the plan and the matrix disagree", - call. = FALSE - ) -} -shard <- mine[[1]] -members <- vapply(shard$packages, function(p) p$name, character(1)) -meta <- read_json(file.path(pkg_dir, "meta.json")) -package <- plan$package - -dir.create(file.path(out_dir, "pkgs"), recursive = TRUE, showWarnings = FALSE) -manifest_path <- file.path(out_dir, "manifest.ndjson") -work <- file.path(env_chr("RUNNER_TEMP", tempdir()), "revdep2-work") -dir.create(work, recursive = TRUE, showWarnings = FALSE) - -# Which half of the shard this invocation runs. The workflow calls the driver -# twice so that Actions times the install and the checks separately; `all` is -# for running the whole shard in one process, which is what a local invocation -# wants. The install leaves `install-state.json` behind and the checks read it, -# so the split costs one small file and repeats nothing. -phase <- env_chr("PHASE", "all") -if (!phase %in% c("all", "install", "check")) { - stop("PHASE must be one of \"all\", \"install\", \"check\"", call. = FALSE) -} -do_install <- phase %in% c("all", "install") -do_check <- phase %in% c("all", "check") -install_state <- file.path(work, "install-state.json") - -# Which slice of the shard's packages this invocation checks, as `i/n`. -# -# The driver has always written its results as it goes -- one manifest line per -# package, appended -- so that a shard killed part way through still accounts -# for what it finished. That only helps if someone *uploads* them, and the -# upload was one step at the very end. Shard 16 of run 31951756102 got three -# minutes into a 196-minute check budget before its runner was reclaimed: -# -# ##[error]The runner has received a shutdown signal. -# ##[error]Process completed with exit code 143. -# -# `if: always()` cannot help there -- a reclaimed runner runs nothing further, -# so the upload was skipped and all 87 packages came back `missing`. Slicing -# the check phase into several steps, each followed by an upload, bounds that -# loss to one slice. The slices share `OUT_DIR`, and the artifact is overwritten -# under one name, so the last upload to survive carries everything before it. -check_slice <- local({ - raw <- trimws(env_chr("CHECK_SLICE")) - if (!nzchar(raw)) { - return(list(index = 1L, of = 1L)) - } - parts <- suppressWarnings(as.integer(strsplit(raw, "/", fixed = TRUE)[[1]])) - if ( - length(parts) != 2 || - anyNA(parts) || - parts[[1]] < 1 || - parts[[2]] < 1 || - parts[[1]] > parts[[2]] - ) { - stop("CHECK_SLICE must be `i/n` with 1 <= i <= n, not ", raw, call. = FALSE) - } - list(index = parts[[1]], of = parts[[2]]) -}) -last_slice <- check_slice$index == check_slice$of - -inform( - "Shard ", - shard_index, - ": ", - length(members), - " package(s), ", - "estimated ~", - shard$estimate_minutes, - " min" -) - -# The running state per package; every entry ends up as one manifest line. -state <- new.env(parent = emptyenv()) -for (p in shard$packages) { - assign( - p$name, - list( - package = p$name, - version = p$version, - level = p$level %||% 0L, - shard = shard_index, - weight_minutes = p$weight_minutes, - t_total = p$t_total %||% 0, - dep_fingerprint = p$dep_fingerprint, - baseline_planned = isTRUE(p$baseline), - # Whether the old check reproduced the baseline this run was offered. - # NA when there was none to compare against. - baseline_agrees = NA, - result = "deferred", - status = "", - status_old = "", - status_new = "", - new_issues = 0L, - t_old = NA, - t_new = NA, - old_checked_at = NA, - message = "" - ), - envir = state - ) -} -update <- function(name, ...) { - entry <- get(name, envir = state) - entry[names(list(...))] <- list(...) - assign(name, entry, envir = state) - entry -} - -counts <- function(x) { - if (!inherits(x, "rcmdcheck")) { - return("?") - } - sprintf( - "%dE %dW %dN", - length(x$errors), - length(x$warnings), - length(x$notes) - ) -} - -# ---------------------------------------------------------------- install ---- - -# The two versions cascade rather than replace each other. -# -# `R_LIBS` is a search path, so a check can name a library holding exactly one -# package -- the CRAN release, or the dev build -- in front of the shared -# library holding every dependency. Nothing is installed or uninstalled -# between the phases, which is what used to force them to run one after the -# other; now both can run at once against libraries that differ in exactly the -# package under test. -lib <- .libPaths()[[1]] -lib_old <- file.path(work, "lib-old") -lib_new <- file.path(work, "lib-new") - -if (do_install) { - install <- unlist(shard$install, use.names = FALSE) - - # What is already built, unpacked into the library pak installs into: this - # run's own preflight library first -- it is the freshest there is, and - # without it every shard would rebuild what the preflight compiled minutes - # ago -- then the earlier runs the plan picked, for whatever the preflight - # could not supply. pak still resolves the whole set afterwards; the point is - # to skip *building* what has not changed, not to skip resolving it. - restore_started <- Sys.time() - restored <- c( - restore_local_library(env_chr("LIB_DIR"), lib, install), - restore_prebuilt(plan, lib, install) - ) - restore_seconds <- elapsed(restore_started) - inform( - length(restored), - " dependency binaries restored, ", - length(install) - length(restored), - " left to pak" - ) - - # With a restored library, `upgrade = FALSE` would freeze whatever version the - # donor happened to hold; the plan's dependency fingerprints are computed from - # CRAN *now*, so the library has to follow CRAN now. - upgrade <- length(restored) > 0 - - # The pak cache this shard restored was saved by the preflight, so a metadata - # database broken there arrives here intact -- which is how run 31282820357 - # turned one bad preflight into sixty shards that installed nothing and - # reported every one of their packages as a depfail. Asking pak what it can - # see costs seconds, and a shard that cannot see CRAN is worth saying out loud - # rather than working around. - if (identical(ensure_metadata(sprintf("Shard %d", shard_index)), "broken")) { - inform("pak cannot see CRAN here; every check will be a depfail") - } - - # In dependency order, a hundred at a time, for the same reason the preflight - # does it: one pak call for the whole set is one resolution of the whole set, - # and that is the part that stops degrading gracefully as the set grows. A - # shard's union is a fraction of the preflight's, but it is the same call. - chunk_size <- env_num("REVDEP2_INSTALL_CHUNK", 400) - chunks <- install_chunks(install, cran_db(), chunk_size) - inform( - "Installing ", - length(install), - " dependencies in ", - length(chunks), - " chunk(s) of at most ", - chunk_size, - ", dependencies first" - ) - install_started <- Sys.time() - # The install gets a slice of the shard's time, not all of it. - # - # It used to be handed the shard's whole deadline, on the reasoning that an - # install running into it "leaves no time to check anything". That is true - # and it is the wrong conclusion: a shard that spends its entire budget - # installing reports *nothing at all*, where one that stops early reports a - # depfail for the packages it could not install and a real verdict for the - # rest. Shard 3 of run 31893156685 sat in this step for 2 h 33 m and had to - # be cancelled; its 50 packages came back `missing`, which is the one result - # that says nothing to anybody. - # - # 45 minutes, from the 39 shard installs the last two runs measured: - # median 9.4, p90 13.7, worst 16.6. So the budget is 2.7x the worst install - # anyone has actually seen, and 15% of the shard's deadline -- loose enough - # that a healthy shard can never notice it, tight enough that shard 3's - # 2 h 33 m would have been cut off more than three times sooner. - # - # Doubled where little was restored, because every one of those 39 had its - # preflight library (95% of the union or better) and a cold install is - # therefore unmeasured. A preflight that dies is survivable by design -- more - # so since it became a `continue-on-error` step -- so cold shards will - # happen, and the one thing worse than a slow install is depfailing 50 - # packages that would have installed given a few more minutes. - install_budget <- env_num("REVDEP2_SHARD_INSTALL_MINUTES", 45) - if (length(restored) < 0.5 * length(install)) { - install_budget <- 2 * install_budget - inform(sprintf( - "Only %d of %d dependencies were restored; allowing %.0f min to install", - length(restored), - length(install), - install_budget - )) - } - install_deadline <- min(deadline, Sys.time() + install_budget * 60) - bulk_ok <- install_in_chunks( - chunks, - lib = lib, - upgrade = upgrade, - deadline = install_deadline - ) - if (!bulk_ok) { - # Which packages are missing is a question about the filesystem, and - # `missing_from()` answers it that way -- as the preflight already did. - # Asking `requireNamespace()` instead *loads* each one: hundreds of - # namespaces and their DLLs into the driver process, and past - # `R_MAX_NUM_DLLS` (614) it starts returning FALSE for packages that are - # installed, so the loop reinstalls them. And `next` on the deadline only - # skipped the install, after the namespace had been loaded; it never - # stopped. - for (p in missing_from(lib, install)) { - if (Sys.time() > install_deadline) { - inform("Past the install deadline; the rest is left to depfail") - break - } - run <- pak_install( - p, - lib = lib, - upgrade = upgrade, - timeout_seconds = install_timeout_seconds(), - label = paste("installing", p) - ) - if (!run$ok) { - inform("Could not install ", p, ": ", run$message) - } - } - } - - # After the installs and before the first check: pak has covered whatever it - # installed itself, so what is left is exactly the restored packages -- this - # run's preflight library and the plan's donors. A shard has no load test, so - # an unmet system requirement here would surface as a check failure blamed on - # the revdep. - ensure_sysreqs(lib, sprintf("Shard %d", shard_index)) - - # And the requirements of the packages this shard will *check*, which the - # survey above cannot see: they are never installed into the library, `R CMD - # check` builds each one from its tarball. - ensure_check_sysreqs(members, sprintf("Shard %d", shard_index)) - - install_seconds <- elapsed(install_started) - inform( - "Dependencies ready after ", - round(install_seconds / 60, 1), - " min (", - round(restore_seconds / 60, 1), - " min unpacking prebuilt)" - ) - - dir.create(lib_old, recursive = TRUE, showWarnings = FALSE) - dir.create(lib_new, recursive = TRUE, showWarnings = FALSE) - - # The shared library must not hold the package under test at all, or it would - # shadow neither and both checks would see whatever the resolver left there. - unlink(file.path(lib, package), recursive = TRUE) - - inform( - "Installing ", - package, - " ", - plan$cran_version, - " into the old library" - ) - cran_install <- pak_install( - package, - lib = lib_old, - upgrade = FALSE, - timeout_seconds = install_timeout_seconds(), - label = paste("installing", package) - ) - if (!cran_install$ok) { - stop("Installing the CRAN release of ", package, " failed", call. = FALSE) - } - our_cran_version <- as.character(utils::packageVersion(package, lib_old)) - if (!identical(our_cran_version, plan$cran_version)) { - inform( - "Note: old checks run against ", - our_cran_version, - " (the repositories lag CRAN, the plan expected ", - plan$cran_version, - ")" - ) - } - - binary <- file.path(pkg_dir, meta$binary) - inform("Installing dev binary ", basename(binary), " into the new library") - if ( - system2( - "R", - c("CMD", "INSTALL", "-l", shQuote(lib_new), shQuote(binary)) - ) != - 0 - ) { - stop("Installing the prebuilt dev binary failed", call. = FALSE) - } - our_dev_version <- as.character(utils::packageVersion(package, lib_new)) - - # What the check phase needs to know about this one, and what the timings at - # the end report. Everything else it can work out for itself from the library - # it finds on disk. - write_json( - list( - install_packages = length(install), - restored = length(restored), - restore_seconds = restore_seconds, - install_seconds = install_seconds, - our_cran_version = our_cran_version, - our_dev_version = our_dev_version, - # When the shard's clock started, and what the install phase spent of it. - # Both matter to the phase that follows: it has to finish inside the same - # job, and its own `script_seconds` is no longer the whole driver. - started_at = format(script_started, "%Y-%m-%dT%H:%M:%SZ", tz = "UTC"), - phase_seconds = elapsed(script_started) - ), - install_state - ) -} - -if (!do_check) { - inform("Install phase complete; the check phase runs as its own step") - quit(save = "no", status = 0) -} - -# An install phase that never finished leaves no state, and there is nothing -# to check without the two one-package libraries it builds. But every package -# still has to be *accounted for*: `missing` -- which is what the collector -# reports for a shard that uploaded nothing -- says only that a job died, while -# a manifest full of `error` says which shard, and why, and is picked up by -# `retry-run` just the same. Shard 3 of run 31893156685 lost 50 packages to -# exactly this. -if (!file.exists(install_state)) { - reason <- sprintf( - "shard %d: the install phase did not finish, so nothing could be checked", - shard_index - ) - inform(reason) - invisible(file.create(manifest_path)) - for (name in members) { - update(name, result = "error", message = reason) - entry <- get(name, envir = state) - entry$our_cran_version <- plan$cran_version - entry$our_dev_version <- plan$dev_version - cat( - jsonlite::toJSON(entry, auto_unbox = TRUE, null = "null"), - "\n", - sep = "", - file = manifest_path, - append = TRUE - ) - } - append_summary(c( - if (check_slice$of > 1L) { - sprintf("### Shard %d, slice %d/%d", shard_index, check_slice$index, check_slice$of) - } else { - sprintf("### Shard %d", shard_index) - }, - "", - sprintf("%d package(s) not checked: %s.", length(members), reason) - )) - quit(save = "no", status = 0) -} -installed_state <- read_json(install_state) -our_cran_version <- installed_state$our_cran_version -our_dev_version <- installed_state$our_dev_version - -# The deadline belongs to the *shard*, not to this process. -# -# `deadline` was computed at the top of the script, so the check phase gave -# itself a fresh 300 minutes on top of whatever the install phase had already -# spent -- and the job's own `timeout-minutes: 350` covers their sum. A shard -# with a 50-minute install could then be killed mid-check by Actions instead of -# stopping itself and deferring, which is the one thing the deadline exists to -# prevent. Rebased on when the install phase started. -shard_started <- tryCatch( - as.POSIXct( - installed_state$started_at, - format = "%Y-%m-%dT%H:%M:%SZ", - tz = "UTC" - ), - error = function(e) NA -) -if (!is.na(shard_started)) { - deadline <- shard_started + env_num("DEADLINE_MINUTES", 300) * 60 - inform(sprintf( - "Install phase took %s; %s of the shard's deadline left for checks", - format_duration(installed_state$phase_seconds %||% 0), - format_duration(max(0, as.numeric(deadline - Sys.time(), units = "secs"))) - )) -} -invisible(file.create(manifest_path)) - -# What a check will be able to load, which is not the same as what is in the -# shared library: the package under test is deliberately *not* there. It is -# unlinked at the end of the install phase so that neither half's cascading -# library is shadowed by it, and it lives in `lib-old` and `lib-new` instead -- -# neither of which is on this process's `.libPaths()`, because only -# `check-pair.sh` puts them on `R_LIBS`. -# -# Asking the bare `installed.packages()` therefore reports the package under -# test as missing, and `strong_missing()` below then reports it missing for -# every revdep that depends on it strongly -- which is every revdep, under -# `which: strong`. The whole shard would come back `depfail` having checked -# nothing. Before the install and check phases were split this line ran while -# the package was still in the shared library, so the question never arose. -installed <- rownames(utils::installed.packages( - lib.loc = c(.libPaths(), lib_old) -)) - -# One manifest line, appended as soon as the package has one. -reported <- character() -write_manifest_line <- function(entry) { - entry$our_cran_version <- our_cran_version - entry$our_dev_version <- our_dev_version - cat( - jsonlite::toJSON(entry, auto_unbox = TRUE, null = "null"), - "\n", - sep = "", - file = manifest_path, - append = TRUE - ) -} -inform(sprintf( - "Checking old (%s) and new (%s) concurrently, two at a time per package", - our_cran_version, - our_dev_version -)) - -# A package whose *strong* dependency closure is incomplete cannot produce a -# check result worth comparing; missing suggests are tolerable, the check runs -# with _R_CHECK_FORCE_SUGGESTS_=false, the way CRAN treats unavailable ones. -db <- cran_db() -strong_missing <- function(name) { - strong <- tools::package_dependencies( - name, - db = db, - which = "strong", - recursive = TRUE - )[[1]] - setdiff(intersect(strong, rownames(db)), c(installed, base_packages())) -} -runnable <- character() -for (name in members) { - missing <- tryCatch(strong_missing(name), error = function(e) character()) - if (length(missing) > 0) { - update( - name, - result = "depfail", - message = paste( - "Dependencies not installed:", - paste(missing, collapse = ", ") - ) - ) - inform( - name, - ": dependencies missing (", - paste(missing, collapse = ", "), - ")" - ) - } else { - runnable <- c(runnable, name) - } -} - -# ---------------------------------------------------------------- sources ---- - -src_dir <- file.path(work, "src") -dir.create(src_dir, showWarnings = FALSE) -sources <- list() -for (name in runnable) { - tarball <- tryCatch( - { - hit <- utils::download.packages( - name, - destdir = src_dir, - repos = cran_repo(), - type = "source", - quiet = TRUE - ) - hit[1, 2] - }, - error = function(e) NULL - ) - if (is.null(tarball)) { - update( - name, - result = "error", - message = "Source tarball could not be downloaded" - ) - inform(name, ": source download failed") - } else { - sources[[name]] <- tarball - actual <- sub( - sprintf("^%s_(.*)[.]tar[.]gz$", name), - "\\1", - basename(tarball) - ) - update(name, version = actual) - } -} -runnable <- names(sources) - -# Dealt round robin rather than in blocks. `runnable` is heaviest first, so a -# contiguous cut would put every long check in the first slice and leave the -# last one with nothing but the cheap ones -- and the deadline, which stops the -# shard when the next check will not fit, would then bite unevenly. Round robin -# gives every slice the same mix. -# -# Not `seq(index, length(runnable), by = of)`: seq() refuses a `from` past -# `to` ("wrong sign in 'by' argument"), so that spelling is an R *error* for a -# shard with fewer runnable packages than slices. A one-package shard, the -# common retry case, checked its package in slice 1 and then crashed slices 2 -# and 3, turning the job red; an empty `runnable` -- every package a depfail -- -# crashed slice 1 before a single manifest line was written. Filtering the -# positions lets such a slice select nothing and check nothing. -if (check_slice$of > 1L) { - mine <- seq_along(runnable) - mine <- mine[mine %% check_slice$of == check_slice$index %% check_slice$of] - inform(sprintf( - "Slice %d/%d: %d of this shard's %d runnable package(s)", - check_slice$index, - check_slice$of, - length(mine), - length(runnable) - )) - runnable <- runnable[mine] -} - -# ------------------------------------------------------------------ checks --- - -# Stop before a check the trailing estimate says will not finish -- but always -# attempt the first check of a phase, or a mis-budgeted shard would make no -# progress at all and a retry would repeat the mistake. -checks_started <- 0L -check_seconds <- 0 -out_of_time <- function(entry) { - if (checks_started == 0L) { - return(FALSE) - } - budget_sec <- max(entry$weight_minutes, 1) * 60 * 1.3 - Sys.time() + budget_sec > deadline -} - -# One package, both versions, at once. -# -# check-pair.sh runs the two `R CMD check` invocations concurrently against the -# cascading libraries and writes each one's log and exit status; this reads -# them back. `rcmdcheck::parse_check()` turns a 00check.log into the same -# object `rcmdcheck()` used to return, so everything downstream -- the counts, -# `compare_checks()`, the manifest -- is unchanged. -# -# The timeout is coreutils' rather than rcmdcheck's, which is what makes the -# distinction reliable: exit 124 is the deadline, anything else is the check -# saying something. -# The check log with this run's incidentals taken out of it. -# -# Two things differ between the halves for reasons that have nothing to do with -# the package: -# -# * the paths. The libraries cascade, so they differ by construction -- -# `.../lib-old/...` against `.../lib-new/...` -- and so do the two check -# directories, which the log names in its first line and quotes in every -# "see ... for details". -# * the timings. `--as-cran` sets `_R_CHECK_TIMINGS_`, so every stage slower -# than ten seconds prints its own `[user/elapsed]` pair, and two checks -# racing each other for the same four cores never agree on those. A run of -# rphylopic against the *same* igraph on both sides differed in exactly two -# lines: the log directory, and `[14s/12s]` against `[13s/11s]`. -# -# Both matter twice. `compare_checks()` matches issues by their text, so a -# difference in the first line of an issue makes an issue both halves have look -# like a new one; and the diff between the halves is only worth printing if two -# identical results produce an empty one. -# -# Nothing else is touched: a difference anywhere but here is exactly what this -# workflow exists to find. -neutral_log <- function(path, name) { - lines <- readLines(path, warn = FALSE) - for (from in c(lib_old, lib_new, file.path(work, "check", name))) { - lines <- gsub(from, "", lines, fixed = TRUE) - } - # The phase also names itself in the .Rcheck path under the work directory. - lines <- gsub("/(old|new)", "", lines) - # `[14s/12s]`, and the one-number form R uses where it has only one. - gsub("\\[[0-9.]+s(/[0-9.]+s)?\\]", "[]", lines) -} - -# How much of a package's diff goes into the job log before it is cut off. -diff_max_lines <- env_num("REVDEP2_DIFF_MAX_LINES", 200) - -# How much of an installation or test transcript goes into the job summary. -# Both are read to find out why something broke, and 80 lines -- the default -# for the check log, which is a summary of stages -- cuts a compiler error or a -# testthat run off in the middle. -detail_max_lines <- env_num("REVDEP2_DETAIL_MAX_LINES", 300) - -# The two halves' check logs, as a patch. -# -# Both sides are neutralised first, so the paths and the stage timings that -# differ in every pair are gone and what is left is the package: an empty diff -# means the dev version changed nothing about this check, however long the log. -check_diff <- function(name, old_log, new_log) { - tmp <- file.path(tempdir(), c("old-00check.log", "new-00check.log")) - writeLines(neutral_log(old_log, name), tmp[[1]]) - writeLines(neutral_log(new_log, name), tmp[[2]]) - on.exit(unlink(tmp), add = TRUE) - suppressWarnings(system2( - "diff", - shQuote(c("-u", "--label", "old", tmp[[1]], "--label", "new", tmp[[2]])), - stdout = TRUE, - stderr = NULL - )) -} - -check_pair <- function(name) { - checks_started <<- checks_started + 1L - work_dir <- file.path(work, "check", name) - unlink(work_dir, recursive = TRUE) - dir.create(work_dir, recursive = TRUE, showWarnings = FALSE) - # The timeout scales with what the check costs CRAN, floored because these - # runners are slower than CRAN's machines and a tiny package must not be - # killed over the difference. - timeout_sec <- max( - timeout_min_sec, - timeout_factor * (get(name, envir = state)$t_total %||% 0) - ) - started <- Sys.time() - system2( - file.path(script_dir, "check-pair.sh"), - shQuote(c( - sources[[name]], - work_dir, - lib_old, - lib_new, - lib, - format(round(timeout_sec), scientific = FALSE) - )) - ) - duration <- round(as.numeric(Sys.time() - started, units = "secs")) - # Both checks ran side by side, so the pair cost what the slower one cost, - # and that -- not the sum of the two -- is what the shard's deadline spends - # and what the cost model is fitted against. - check_seconds <<- check_seconds + duration - - read_side <- function(phase) { - dir <- file.path(work_dir, phase) - # A pair that never wrote its status -- an unwritable work directory, a - # full disk, `check-pair.sh` dying before its last line -- used to throw - # "subscript out of bounds" out of the whole loop. It is one package's - # problem, so it reads as one. - status <- tryCatch( - suppressWarnings(as.integer(readLines( - file.path(dir, "status"), - warn = FALSE - )[[1]])), - error = function(e) NA_integer_ - ) - log <- file.path(dir, paste0(name, ".Rcheck"), "00check.log") - result <- if (identical(status, 124L)) { - simpleError(sprintf( - "%s check timed out after %ds", - phase, - round(timeout_sec) - )) - } else { - tryCatch( - { - # Parsed twice, on purpose. `parse_check()` reads `00install.out` - # and the test transcripts off the check directory it finds named in - # the log's first line -- so parsing the *neutralised* text alone, - # where that path has been replaced by a constant, silently leaves - # `install_out` at "<00install.out file does not exist>" and - # `test_fail` empty, and revdepcheck's failures.md loses exactly the - # output a reader opens it for. So the real file gives the object, - # and the neutralised text gives only the three fields that are - # compared and diffed, where the paths and stage timings would - # otherwise make two identical halves look different. - res <- rcmdcheck::parse_check(log) - neutral <- rcmdcheck::parse_check(text = neutral_log(log, name)) - res$errors <- neutral$errors - res$warnings <- neutral$warnings - res$notes <- neutral$notes - # Who to tell about a broken package. - # - # revdepcheck's reports head each package with its own GitHub, its - # maintainer's email and its CRAN mirror, and it reads all three out - # of `$description` and `$cran` on the result. `rcmdcheck()` filled - # those in because it had the package's source; `parse_check()` - # cannot know them from a log, so every entry in problems.md came out - # as "* : " once the driver switched. The check directory has - # the installed DESCRIPTION sitting in it, and every package here is - # from CRAN by construction. - described <- file.path(dirname(log), name, "DESCRIPTION") - if (file.exists(described)) { - res$description <- paste( - readLines(described, warn = FALSE), - collapse = "\n" - ) - } - res$cran <- TRUE - res - }, - error = function(e) { - simpleError(sprintf( - "%s check produced no readable result (exit %s): %s", - phase, - status, - conditionMessage(e) - )) - } - ) - } - attr(result, "duration") <- duration - attr(result, "timed_out") <- identical(status, 124L) - # Where it was when the clock ran out. A check killed in `tests` is a - # different animal from one killed while compiling, and the report used to - # say only "timed out". - attr(result, "last_step") <- if (file.exists(log)) { - steps <- grep("^[*] ", readLines(log, warn = FALSE), value = TRUE) - if (length(steps) > 0) utils::tail(steps, 1) else "" - } else { - "" - } - result - } - - list(old = read_side("old"), new = read_side("new")) -} - -# Record the half that did produce a result, when its partner did not. -# -# There is nothing to compare, so there is no verdict -- but the check ran, and -# what it found is the only thing anyone will have to go on when they come back -# to the package. Kept where the comparison path keeps it, so `retry-run` and a -# human reading the artifact find it in the usual place. -keep_side <- function(name, phase, result) { - saveRDS(result, file.path(pkg_out(name), paste0(phase, ".rds"))) - if (identical(phase, "old")) { - update( - name, - status_old = counts(result), - t_old = attr(result, "duration"), - old_checked_at = now_utc() - ) - } else { - update( - name, - status_new = counts(result), - t_new = attr(result, "duration") - ) - } - copy_check_output( - file.path(work, "check", name, phase, paste0(name, ".Rcheck")), - file.path(out_dir, "pkgs", name, paste0(phase, "-check")) - ) -} - -# The files worth carrying out of a check directory: what broke, and the -# complete transcripts of the two stages that explain why. -copy_check_output <- function(rcheck, keep) { - dir.create(keep, recursive = TRUE, showWarnings = FALSE) - for (f in c( - "00check.log", - "00install.out", - list.files( - rcheck, - pattern = "[.]Rout[.]fail$|-Ex[.]Rout$", - recursive = TRUE - ) - )) { - if (file.exists(file.path(rcheck, f))) { - file.copy( - file.path(rcheck, f), - file.path(keep, basename(f)), - overwrite = TRUE - ) - } - } -} - -check_failure <- function(name, phase, result, progress) { - if (isTRUE(attr(result, "timed_out"))) { - # `timeout`, not `failed`. A check killed by the clock says nothing about - # the package, and in the old phase it says nothing about our change - # either -- the dev version is not even on that library path. Reporting it - # as a failure put 60 packages into failures.md in run 31304411628 that - # the run had learnt nothing about. `needs_recheck()` picks it up either - # way, so `retry-run` still re-checks them. - step <- attr(result, "last_step") %||% "" - update( - name, - result = "timeout", - message = sprintf( - "%s check timed out after %ds%s", - phase, - attr(result, "duration"), - if (nzchar(step)) paste0(", at: ", trimws(step)) else "" - ) - ) - inform( - name, - ": ", - phase, - " check timed out (", - attr(result, "duration"), - "s)", - if (nzchar(step)) paste0(" at ", trimws(step)) else "", - ", ", - progress - ) - } else { - update(name, result = "error", message = conditionMessage(result)) - inform( - name, - ": ", - phase, - " check errored: ", - conditionMessage(result), - ", ", - progress - ) - } -} - -pkg_out <- function(name) { - dir <- file.path(out_dir, "pkgs", name) - dir.create(dir, recursive = TRUE, showWarnings = FALSE) - dir -} - -# The checks: one pass, both versions of every package at once. -# -# There used to be two passes -- every old check, then the dev binary -# installed over the CRAN one, then every new check -- because the library -# could only hold one version at a time. With the two cascading libraries it -# can hold both, so a package's pair runs together and the shard makes one -# pass. That halves a package's wall clock, and it means a package whose old -# check hangs still gets its new answer instead of the run learning nothing -# about it. -inform(sprintf( - "Checking %d package(s), old and new side by side", - length(runnable) -)) -# How far along the shard is, on every line that reports a package. -# -# A shard runs for hours and its log is read while it runs, so "3/51" answers -# "is this nearly done?" without counting lines. The estimate answers the -# question actually being asked, which is when. -# -# The plan already priced every package; what it could not know is how this -# runner would compare. So the remaining packages are priced in the plan's own -# units and then rescaled by how its estimates have held up here so far -- -# which absorbs both a slow runner and a systematically optimistic model, -# without either having to be known in advance. Before the first pair finishes -# there is nothing to rescale by and the plan's number stands. -planned_done <- 0 -actual_done <- 0 -planned_minutes <- function(name) { - max(get(name, envir = state)$weight_minutes %||% 0, 0) -} -progress_note <- function(position) { - left <- sum(vapply( - utils::tail(runnable, length(runnable) - position), - planned_minutes, - numeric(1) - )) - scale <- if (planned_done > 0 && actual_done > 0) { - actual_done / planned_done - } else { - 1 - } - sprintf( - "%d/%d, %s", - position, - length(runnable), - if (left > 0) { - paste0("~", format_duration(left * scale * 60), " left") - } else { - "last one" - } - ) -} - -# One package: both halves, compared, recorded. Returns nothing; everything it -# learns goes into `state`, and the caller writes that out however this ends. -check_package <- function(name, position) { - entry <- get(name, envir = state) - - if (out_of_time(entry)) { - inform(name, ": deferred (deadline), ", progress_note(position)) - return(invisible(NULL)) - } - - # Both halves, always. A baseline used to stand in for the old check and - # save it; with the pair running concurrently the old check costs no wall - # clock at all, and reusing a result from another run means comparing - # against a machine, a CRAN snapshot and a dependency tree that are not - # this run's. The baseline is still read, but as a second opinion: if it - # disagrees with what the old check just produced, that is drift worth - # printing rather than a comparison worth trusting. - pair <- check_pair(name) - old <- pair$old - new <- pair$new - - # What this one was priced at against what it cost, which is what prices the - # rest. A timed-out check counts too: the clock really did spend it. - planned_done <<- planned_done + planned_minutes(name) - actual_done <<- actual_done + (attr(new, "duration") %||% 0) / 60 - progress <- progress_note(position) - - # A half that produced a result is kept even when its partner did not. - # - # Running the pair concurrently was supposed to mean that "a package whose - # old check hangs still gets its new answer" -- but the old half's error used - # to `next` straight past the code that saves the new one, so the answer was - # produced and then thrown away, and the artifact held nothing at all for - # that package. 19 packages in run 31879790285 lost a half this way. - if (inherits(new, "error")) { - if (!inherits(old, "error")) { - keep_side(name, "old", old) - } - check_failure(name, "new", new, progress) - return(invisible(NULL)) - } - if (inherits(old, "error")) { - keep_side(name, "new", new) - check_failure(name, "old", old, progress) - return(invisible(NULL)) - } - saveRDS(old, file.path(pkg_out(name), "old.rds")) - update( - name, - status_old = counts(old), - t_old = attr(old, "duration"), - old_checked_at = now_utc() - ) - - if (entry$baseline_planned) { - rds <- file.path(baseline_dir, "old-rds", paste0(name, ".rds")) - baseline <- tryCatch(readRDS(rds), error = function(e) NULL) - if (!is.null(baseline)) { - agrees <- identical(counts(baseline), counts(old)) - update(name, baseline_agrees = agrees) - if (!agrees) { - inform(sprintf( - "%s: the baseline said %s, the old check now says %s", - name, - counts(baseline), - counts(old) - )) - } - } - } - saveRDS(new, file.path(pkg_out(name), "new.rds")) - - cmp <- tryCatch( - rcmdcheck::compare_checks(old, new), - error = function(e) NULL - ) - if (is.null(cmp)) { - update( - name, - result = "failed", - status_new = counts(new), - t_new = attr(new, "duration"), - message = "both checks ran, but their results could not be compared" - ) - } else if (aborted_on_dependencies(new) && aborted_on_dependencies(old)) { - # Neither half ran. `compare_checks()` still says `+` -- the two agree, and - # they agree on having done nothing -- so without this the package is - # reported `ok`. It is not ok, it is unknown, and `needs_recheck()` picks - # `depmissing` up so a retry with those repositories enabled re-checks it. - absent <- missing_dependencies(new) - update( - name, - result = "depmissing", - status = cmp$status, - status_new = counts(new), - t_new = attr(new, "duration"), - new_issues = 0L, - message = paste0( - "R CMD check stopped at `checking package dependencies` under both ", - "versions; nothing was checked", - if (length(absent) > 0) { - paste0(" (not installed: ", paste(absent, collapse = ", "), ")") - } - ) - ) - } else { - new_issues <- sum(cmp$cmp$change == 1) - update( - name, - result = classify_status(cmp$status, new_issues), - status = cmp$status, - status_new = counts(new), - # The pair's wall clock, charged to both halves: they ran side by side, - # so neither one's own time is separable from the other's. It used to be - # recorded only where the comparison failed, which left `t_new` null for - # every package that compared -- that is, for all of them. - t_new = attr(new, "duration"), - new_issues = new_issues, - # An install failure or a timeout leaves nothing to compare, so the - # result is only "failed"; say which one it was. - message = status_message(cmp$status) - ) - } - entry <- get(name, envir = state) - inform( - name, - ": ", - entry$result, - " (old ", - entry$status_old, - ", new ", - entry$status_new, - ", ", - attr(new, "duration"), - "s for the pair, ", - progress, - ")" - ) - - # The parsed results carry everything the reports need; raw check output is - # kept only where a human will want to dig, and then as the *difference* - # between the two logs rather than the whole of the new one. The whole log - # is thousands of lines that are identical in both, and what a reader wants - # is the handful that are not. - if (entry$result == "ok") { - unlink(file.path(work, "check", name), recursive = TRUE) - } else { - keep <- file.path(out_dir, "pkgs", name, "new-check") - rcheck <- function(phase) { - file.path(work, "check", name, phase, paste0(name, ".Rcheck")) - } - copy_check_output(rcheck("new"), keep) - old_log <- file.path(rcheck("old"), "00check.log") - new_log <- file.path(rcheck("new"), "00check.log") - if (file.exists(old_log) && file.exists(new_log)) { - diff <- check_diff(name, old_log, new_log) - writeLines(diff, file.path(keep, "00check.diff")) - # And into the job log, where it is the one thing a reader of the run - # actually wants: what the dev version changed about this package, in the - # package's own words. Downloading an artifact to find out that a NOTE - # gained a line is a poor trade. It is bounded because a package that - # fails to install differs in thousands of lines and would bury the rest - # of the shard; the whole diff is in the artifact either way. - print_group( - sprintf("%s: old vs new check log (%d line diff)", name, length(diff)), - if (length(diff) == 0) { - # Worth saying rather than leaving as an empty block. A package - # called `newly_broken` whose two logs are identical once the paths - # and the stage timings are out of them is not newly broken; it is - # this harness getting it wrong, and this line is how a reader of the - # run finds that out without downloading anything. - "The two logs are identical apart from paths and stage timings." - }, - head(diff, diff_max_lines), - if (length(diff) > diff_max_lines) { - sprintf( - "[%d more lines; the whole diff is 00check.diff in the shard artifact]", - length(diff) - diff_max_lines - ) - } - ) - } - unlink(file.path(work, "check", name), recursive = TRUE) - } - invisible(NULL) -} - -for (position in seq_along(runnable)) { - name <- runnable[[position]] - # A driver error is this package's problem, not the shard's. Before, an - # unguarded `readLines(.../status)[[1]]` on a check-pair that never wrote its - # status file -- a full disk, an unwritable work directory -- threw out of - # the loop and took every remaining package with it. - tryCatch( - check_package(name, position), - error = function(e) { - update( - name, - result = "error", - message = paste("driver error:", conditionMessage(e)) - ) - inform(name, ": driver error: ", conditionMessage(e)) - } - ) - - # This package's line, now rather than at the end of the shard. - # - # The file is newline-delimited JSON precisely so that it can be appended to, - # but it used to be written in one pass after the loop -- so a shard killed - # by the job timeout, or thrown out of the loop by an unhandled error, left - # an *empty* manifest next to a full set of results, and `collect.R` skips a - # directory whose manifest has no lines. Hours of finished checks were one - # kill away from being reported as `missing`. Deferred and unreached packages - # are appended at the end, and the collector reconciles the rest against the - # plan. - write_manifest_line(get(name, envir = state)) - reported <- c(reported, name) -} - - -# ---------------------------------------------------------------- manifest --- - -# Whatever the loop never reached: deferred packages, and the ones a depfail or -# a missing source knocked out before it started. -# -# Under slicing this also covers the packages belonging to *later* slices, which -# is deliberate: an interim artifact that says `deferred` for them is the truth -# at that moment, and better than the `missing` the collector would otherwise -# reconcile them into. What it must not do is overwrite a result an *earlier* -# slice already wrote -- those packages are still `deferred` in this process's -# memory, and a later line wins in the collector. So the manifest is read back -# and anything already accounted for is left alone. -already <- if (file.exists(manifest_path)) { - lines <- readLines(manifest_path, warn = FALSE) - lines <- lines[nzchar(trimws(lines))] - vapply( - lines, - function(line) jsonlite::fromJSON(line, simplifyVector = FALSE)$package, - character(1), - USE.NAMES = FALSE - ) -} else { - character() -} -for (name in setdiff(members, c(reported, already))) { - write_manifest_line(get(name, envir = state)) -} -# The summary below is this slice's, not the shard's: the other slices' packages -# are still at their initial `deferred` in this process and would pad every -# table with rows that say nothing. -entries <- lapply( - if (check_slice$of > 1L) reported else members, - function(name) get(name, envir = state) -) - -# ----------------------------------------------------------------- timings --- - -# What this shard cost, next to what the plan thought it would: the collector -# pools these into the run's timings artifact, and the next plan calibrates its -# cost model from them. The job's own minutes -- the runner image, R, TinyTeX, -# the artifact downloads before this script even starts -- are not visible from -# here; the collector reads those off the API and adds them. -# Across slices, not per slice: the collector fits the cost model from these, -# and a `check_seconds` covering a third of the shard next to a `script_seconds` -# covering the job would make every shard look three times cheaper than it is. -earlier <- if (file.exists(file.path(out_dir, "timing.json"))) { - tryCatch(read_json(file.path(out_dir, "timing.json")), error = function(e) { - NULL - }) -} else { - NULL -} -write_json( - list( - index = shard_index, - packages = length(members), - checks = checks_started + (earlier$checks %||% 0L), - install_packages = installed_state$install_packages, - restored = installed_state$restored, - restore_seconds = installed_state$restore_seconds, - install_seconds = installed_state$install_seconds, - check_seconds = round(check_seconds + (earlier$check_seconds %||% 0), 1), - # Both phases, because the collector fits `setup_minutes` as - # `job_minutes - script_minutes` -- the minutes before the driver starts. - # Reporting only this process would have charged the whole install phase to - # "setup", which the plan then seeds every shard's load with *and* prices - # again per dependency, and a setup of tens of minutes instead of six is - # what tips a plan into extra waves. - script_seconds = round( - (installed_state$phase_seconds %||% 0) + elapsed(script_started), - 1 - ), - started_at = installed_state$started_at %||% - earlier$started_at %||% - format(script_started, "%Y-%m-%dT%H:%M:%SZ", tz = "UTC"), - finished_at = now_utc(), - planned_minutes = shard$estimate_minutes, - planned_check_minutes = shard$check_minutes - ), - file.path(out_dir, "timing.json") -) - -# ------------------------------------------------------------------ summary -- - -results <- vapply(entries, function(e) e$result, character(1)) -df <- data.frame( - Package = vapply(entries, function(e) e$package, character(1)), - Version = vapply(entries, function(e) e$version, character(1)), - Result = results, - Old = vapply(entries, function(e) e$status_old, character(1)), - New = vapply(entries, function(e) e$status_new, character(1)), - # `baseline_reused` stopped being set when both halves became mandatory, so - # this column was empty in every row. What the baseline is still good for is - # the drift check -- whether a result from an earlier run still reproduces -- - # and that is what it says now. - Baseline = vapply( - entries, - function(e) { - if (isTRUE(e$baseline_agrees)) { - "agrees" - } else if (isFALSE(e$baseline_agrees)) { - "disagrees" - } else { - "" - } - }, - character(1) - ) -) -append_summary(c( - sprintf("### Shard %d", shard_index), - "", - sprintf( - "%d ok, %d newly broken, %d failed, %d timed out, %d depfail, %d depmissing, %d error, %d deferred.", - sum(results == "ok"), sum(results == "newly_broken"), sum(results == "failed"), - sum(results == "timeout"), - sum(results == "depfail"), sum(results == "depmissing"), - sum(results == "error"), sum(results == "deferred") - ), - "", - md_table(df) -)) -for (entry in entries) { - if (entry$result %in% c("ok", "deferred")) { - next - } - # The reason goes in the title, where `md_details()` cannot tail it away; - # the body is the check log where there is one, because the reason alone - # rarely says which check step broke. - kept <- file.path(out_dir, "pkgs", entry$package, "new-check") - log <- file.path(kept, "00check.log") - reason <- gsub("\n", " ", entry$message %||% "") - lines <- if (file.exists(log)) { - readLines(log, warn = FALSE) - } else if (nzchar(reason)) { - strsplit(entry$message, "\n")[[1]] - } else { - "(no log captured)" - } - title <- sprintf( - "%s — %s%s", - entry$package, - entry$result, - if (nzchar(reason)) paste0(": ", md_escape_html(reason)) else "" - ) - append_summary(md_details(title, lines)) - - # The check log says what broke; these say why, and none of them fits in it. - # `00install.out` is where a package that could not be installed explains - # itself -- the check log only points at the file, which used to mean - # downloading the artifact to read a compiler error. A `.Rout.fail` is a - # failed test file's whole transcript and `-Ex.Rout` the examples', where the - # check log carries a bounded excerpt. Each gets its own block and its own - # budget rather than sharing one, or the tail of the set would be all anyone - # saw. - for (extra in list( - list(file = "00install.out", what = "installation output"), - list( - file = list.files(kept, pattern = "[.]Rout[.]fail$"), - what = "test output" - ), - list( - file = list.files(kept, pattern = "-Ex[.]Rout$"), - what = "example output" - ) - )) { - for (f in extra$file) { - path <- file.path(kept, f) - if (!file.exists(path)) { - next - } - append_summary(md_details( - sprintf( - "%s — %s (%s)", - entry$package, - extra$what, - f - ), - readLines(path, warn = FALSE), - max_lines = detail_max_lines - )) - } - } -} - -inform( - "Shard ", - shard_index, - " done: ", - paste(names(table(results)), table(results), sep = "=", collapse = ", ") -) diff --git a/.github/workflows/revdep2/util.R b/.github/workflows/revdep2/util.R deleted file mode 100644 index f0ed1a9..0000000 --- a/.github/workflows/revdep2/util.R +++ /dev/null @@ -1,1676 +0,0 @@ -# Shared helpers for the revdep2 workflow scripts. -# Sourced by plan.R, build.R, shard.R and collect.R; base R plus jsonlite only, -# so every job can use it before any heavyweight dependency is installed. - -# ------------------------------------------------------------- environment -- - -`%||%` <- function(x, y) if (is.null(x)) y else x - -env_chr <- function(name, default = "") { - value <- Sys.getenv(name, unset = "") - if (identical(value, "")) default else value -} - -env_num <- function(name, default) { - value <- env_num_opt(name) - if (is.null(value)) default else value -} - -# The same, but NULL when the variable is unset or unusable -- so a caller can -# tell "not given" from "given the value that happens to be the default", which -# is what an explicit knob overriding a measurement needs to know. -env_num_opt <- function(name) { - value <- suppressWarnings(as.numeric(env_chr(name))) - if (length(value) != 1 || is.na(value)) NULL else value -} - -env_flag <- function(name) { - tolower(env_chr(name)) %in% c("1", "true", "yes") -} - -inform <- function(...) { - message(paste0(...)) -} - -now_utc <- function() { - format(Sys.time(), "%Y-%m-%dT%H:%M:%SZ", tz = "UTC") -} - -# A block of output under a heading the reader can fold away. -# -# Actions renders `::group::` as a collapsed section in the job log, so a -# hundred shard packages can each contribute their diff without any of them -# getting in the way of the summary lines between them. Outside Actions the -# markers are just two extra lines. NULL arguments are dropped, so a caller can -# pass a trailing note conditionally. -print_group <- function(title, ...) { - body <- unlist(list(...), use.names = FALSE) - message("::group::", title) - if (length(body) > 0) { - message(paste(body, collapse = "\n")) - } - message("::endgroup::") -} - -# ---------------------------------------------------------------- run ids ---- - -# GitHub run ids passed `.Machine$integer.max` in 2026, so they are carried as -# strings everywhere here: `as.integer("31048405399")` is a silent NA, and an -# NA reaching `if (run > 0)` takes the whole planning job down. "0" is the -# "no such run" sentinel the workflow's job outputs and plan.json use. -run_id_chr <- function(x) { - if (is.null(x) || length(x) != 1 || is.na(x)) "0" else trimws(as.character(x)) -} - -has_run <- function(x) { - id <- run_id_chr(x) - nzchar(id) && !identical(id, "0") -} - -# ------------------------------------------------------- links in summaries -- - -# A job summary is rendered at the run's own URL, so every link it carries has -# to be absolute; relative ones resolve against /actions/runs/ and 404. - -# A run id, linked to its page; plain text off GitHub (a local run). -run_link <- function(x) { - id <- run_id_chr(x) - if (!nzchar(gh_repo())) { - return(id) - } - sprintf( - "[%s](%s/%s/actions/runs/%s)", - id, - env_chr("GITHUB_SERVER_URL", "https://github.com"), - gh_repo(), - id - ) -} - -# This run's page, where its artifacts are. -this_run_link <- function(text = env_chr("GITHUB_RUN_ID", "local")) { - id <- run_id_chr(env_chr("GITHUB_RUN_ID")) - if (!has_run(id) || !nzchar(gh_repo())) { - return(text) - } - sprintf( - "[%s](%s/%s/actions/runs/%s)", - text, - env_chr("GITHUB_SERVER_URL", "https://github.com"), - gh_repo(), - id - ) -} - -# A revdep, linked to its CRAN page -- the one page about it that is reachable -# from a job summary, and the one that names its maintainer. -cran_link <- function(package) { - sprintf("[%s](https://cran.r-project.org/package=%s)", package, package) -} - -# ------------------------------------------------------------------- JSON ---- - -write_json <- function(x, path) { - jsonlite::write_json(x, path, auto_unbox = TRUE, digits = NA, null = "null") -} - -read_json <- function(path, simplify = FALSE) { - jsonlite::read_json(path, simplifyVector = simplify) -} - -# --------------------------------------------------------- GitHub plumbing ---- - -# Append `name=value` to the job's outputs. Values must be single-line. -set_output <- function(name, value) { - path <- Sys.getenv("GITHUB_OUTPUT") - if (nzchar(path)) { - cat(sprintf("%s=%s\n", name, value), file = path, append = TRUE) - } else { - inform("[output] ", name, "=", value) - } -} - -# Append markdown lines to the job summary, or echo them locally. -append_summary <- function(lines) { - path <- Sys.getenv("GITHUB_STEP_SUMMARY") - if (nzchar(path)) { - cat(lines, file = path, sep = "\n", append = TRUE) - cat("\n", file = path, append = TRUE) - } else { - cat(lines, sep = "\n") - cat("\n") - } -} - -# A minimal pipe table so summaries do not need knitr. -md_table <- function(df) { - esc <- function(x) gsub("|", "\\|", as.character(x), fixed = TRUE) - header <- paste0("| ", paste(esc(names(df)), collapse = " | "), " |") - rule <- paste0("|", paste(rep(" --- ", ncol(df)), collapse = "|"), "|") - rows <- vapply( - seq_len(nrow(df)), - function(i) { - paste0("| ", paste(esc(unlist(df[i, ])), collapse = " | "), " |") - }, - character(1) - ) - c(header, rule, rows) -} - -# Drop one markdown section: the first heading matching `heading`, and -# everything under it up to the next heading of any level. -drop_section <- function(lines, heading) { - at <- grep(heading, lines) - if (length(at) == 0) { - return(lines) - } - from <- at[[1]] - later <- grep("^#+[[:space:]]", lines) - later <- later[later > from] - to <- if (length(later) == 0) length(lines) else later[[1]] - 1L - lines[-seq(from, to)] -} - -# Text going into an HTML fragment of a summary (a title, say), -# where markdown's escaping does not apply. -md_escape_html <- function(x) { - x <- gsub("&", "&", x, fixed = TRUE) - x <- gsub("<", "<", x, fixed = TRUE) - gsub(">", ">", x, fixed = TRUE) -} - -# Strip ANSI escapes and carriage returns before quoting logs into markdown. -sanitize_log <- function(lines) { - lines <- gsub("\r", "", lines, fixed = TRUE) - gsub("\033\\[[0-9;?]*[a-zA-Z]", "", lines) -} - -# Fence log text so that embedded triple backticks cannot break the summary. -md_details <- function(title, lines, max_lines = 80) { - lines <- sanitize_log(lines) - omitted <- character() - if (length(lines) > max_lines) { - omitted <- sprintf( - "... (%d earlier lines omitted)", - length(lines) - max_lines - ) - lines <- utils::tail(lines, max_lines) - } - c( - sprintf("
%s", title), - "", - "````text", - omitted, - lines, - "````", - "", - "
", - "" - ) -} - -# --------------------------------------------------------- gh and artifacts -- - -# The plan resolves donor runs through the API, and the preflight and the -# shards fetch artifacts off them. Everything here is an optimization: a -# missing gh, a token without `actions: read`, an expired artifact, a network -# hiccup -- all of them have to end as "reuse nothing", never as a failure. - -gh_repo <- function() { - env_chr("GITHUB_REPOSITORY") -} - -gh_ok <- function() { - nzchar(Sys.which("gh")) && - nzchar(env_chr("GH_TOKEN", env_chr("GITHUB_TOKEN"))) -} - -# `gh`, with its arguments quoted for the shell: system2() quotes the command -# but hands the arguments to `sh` as written, and these carry `?`, `&`, `|`, -# quotes and spaces. Unquoted, an API path ends at its first `&`, and what -# follows becomes a second command the shell cannot find. -# -# Every failure becomes NULL, including the ones system2() raises instead of -# returning: a command the shell cannot run exits 127, which `stdout = TRUE` -# turns into an R error rather than a status. -gh_lines <- function(...) { - out <- tryCatch( - suppressWarnings( - system2("gh", shQuote(c(...)), stdout = TRUE, stderr = NULL) - ), - error = function(e) NULL - ) - status <- attr(out, "status") - if (is.null(out) || (!is.null(status) && status != 0)) NULL else out -} - -# The unexpired artifacts of one run, as a named character vector of ids; -# empty when the run has none or when gh cannot say. -# -# Paginated, because a single page is not enough and the ones that fall off it -# are exactly the ones that matter. A run publishes one -# `revdep2-results--` per shard -- up to 250 -- and uploads -# `revdep2-baseline`, `revdep2-timings` and `revdep2-report` last of all. Ask -# for one page of 100 and a run with a hundred shards hides its baseline behind -# its results: reuse would silently stop and `retry-run` would fail outright, -# both for a reason no log would name. -run_artifacts <- function(run_id) { - if (!gh_ok() || !nzchar(gh_repo())) { - return(character()) - } - out <- gh_lines( - "api", - "--paginate", - sprintf( - "repos/%s/actions/runs/%s/artifacts?per_page=100", - gh_repo(), - run_id - ), - "--jq", - ".artifacts[] | select(.expired == false) | [.name, .id] | @tsv" - ) - out <- out[nzchar(out)] - if (length(out) == 0) { - return(character()) - } - parts <- strsplit(out, "\t", fixed = TRUE) - stats::setNames( - vapply(parts, function(p) p[[2]], character(1)), - vapply(parts, function(p) p[[1]], character(1)) - ) -} - -# Everything below reports why it could not do its job, not only that it -# could not. These fetches are optimizations, so a failure is swallowed and -# the run continues -- which is exactly the situation where a silent one is -# expensive: a prebuilt library that does not arrive costs an hour of -# rebuilding, and the causes (an artifact that really is gone, a download -# that failed, a truncated zip, an unzip that refused it) call for entirely -# different fixes and used to look identical in the log. - -# The last few lines of what a command wrote to stderr, which is where every -# one of these tools says what went wrong. -stderr_tail <- function(path, n = 3) { - if (!file.exists(path)) { - return("") - } - lines <- tryCatch(readLines(path, warn = FALSE), error = function(e) { - character() - }) - paste(utils::tail(lines[nzchar(trimws(lines))], n), collapse = "; ") -} - -format_bytes <- function(n) { - if (!is.finite(n) || n < 0) { - return("unknown size") - } - if (n >= 1024^3) { - sprintf("%.2f GiB", n / 1024^3) - } else if (n >= 1024^2) { - sprintf("%.1f MiB", n / 1024^2) - } else { - sprintf("%.0f B", n) - } -} - -# utils::unzip() refuses archives above 4 GB, which a library artifact reaches -# without trying; the system unzip has no such limit, so prefer it and keep -# the internal one for a runner without it. -# -# Returns TRUE, or a string saying why not -- both are truthy in R, so callers -# must test with isTRUE(). -unzip_into <- function(zip, dest) { - dir.create(dest, recursive = TRUE, showWarnings = FALSE) - if (nzchar(Sys.which("unzip"))) { - err <- tempfile(fileext = ".err") - on.exit(unlink(err), add = TRUE) - status <- tryCatch( - suppressWarnings( - # Quoted: system2() quotes the command, but not the arguments. - system2( - "unzip", - shQuote(c("-q", "-o", zip, "-d", dest)), - stdout = NULL, - stderr = err - ) - ), - error = function(e) 1L - ) - if (identical(as.integer(status), 0L)) { - return(TRUE) - } - detail <- stderr_tail(err) - return(sprintf( - "unzip exited %d%s", - as.integer(status), - if (nzchar(detail)) paste0(": ", detail) else "" - )) - } - # A gh that wrote an error body instead of the artifact leaves something - # that is not a zip; that is a missing artifact, not a usable one. - extracted <- tryCatch( - suppressWarnings(utils::unzip(zip, exdir = dest)), - error = function(e) character() - ) - if (length(extracted) > 0) TRUE else "utils::unzip() extracted nothing" -} - -# Download one artifact by id into a directory; NULL when it cannot be had, -# with a line in the log saying which of the ways it failed. -fetch_artifact_id <- function(id, dest, what = paste("artifact", id)) { - if (!gh_ok() || !nzchar(gh_repo())) { - inform(what, ": not fetched (no gh, or no token with `actions: read`)") - return(NULL) - } - zip <- tempfile(fileext = ".zip") - on.exit(unlink(zip), add = TRUE) - err <- tempfile(fileext = ".err") - on.exit(unlink(err), add = TRUE) - started <- Sys.time() - # Quoted: system2() quotes the command, but not the arguments. - args <- shQuote(c("api", sprintf("repos/%s/actions/artifacts/%s/zip", gh_repo(), id))) - status <- tryCatch( - suppressWarnings(system2("gh", args, stdout = zip, stderr = err)), - error = function(e) 1L - ) - elapsed <- as.numeric(difftime(Sys.time(), started, units = "secs")) - bytes <- if (file.exists(zip)) file.size(zip) else 0 - if (!identical(as.integer(status), 0L) || bytes == 0) { - detail <- stderr_tail(err) - inform(sprintf( - "%s: download failed after %.0f s -- gh exited %d, %s written%s", - what, - elapsed, - as.integer(status), - format_bytes(bytes), - if (nzchar(detail)) paste0(": ", detail) else "" - )) - return(NULL) - } - inform(sprintf( - "%s: downloaded %s in %.0f s (%.0f MB/s)", - what, - format_bytes(bytes), - elapsed, - bytes / 1e6 / max(elapsed, 1) - )) - unpacked <- unzip_into(zip, dest) - if (!isTRUE(unpacked)) { - inform(what, ": the download is not usable -- ", unpacked) - return(NULL) - } - dest -} - -# Fetch one named artifact of one run; NULL when the run does not have it, it -# has expired, or it cannot be downloaded -- and the log says which. -fetch_artifact <- function(run_id, name, dest) { - ids <- run_artifacts(run_id) - id <- unname(ids[names(ids) == name]) - what <- sprintf("%s of run %s", name, run_id) - if (length(id) == 0) { - inform( - what, - ": the run has no unexpired artifact by that name", - if (length(ids) > 0) { - paste0(" (it has: ", paste(sort(names(ids)), collapse = ", "), ")") - } else { - " (and none at all, or gh could not list them)" - } - ) - return(NULL) - } - fetch_artifact_id(id[[1]], dest, what) -} - -# ------------------------------------------------------ prebuilt libraries -- - -# A run's installed dependency library, carried to the next run as an -# artifact: one tar of the package directories, plus the index a later plan -# reads to decide which packages that run is good for. -# -# The tar is stored uncompressed on purpose -- upload-artifact zips what it -# uploads, and deflating a few gigabytes twice buys nothing. Packing the -# directories by name (rather than the library itself) keeps the member paths -# at `/...`, which is what a partial extraction asks for. - -# Package directories of `lib` that look installed, with their versions. -library_versions <- function(lib) { - pkgs <- list.dirs(lib, full.names = FALSE, recursive = FALSE) - pkgs <- pkgs[file.exists(file.path(lib, pkgs, "DESCRIPTION"))] - versions <- vapply( - pkgs, - function(p) { - tryCatch( - unname(read.dcf(file.path(lib, p, "DESCRIPTION"), "Version")[1, 1]), - error = function(e) NA_character_ - ) - }, - character(1) - ) - versions[!is.na(versions)] -} - -pack_library <- function(lib, dest, index_dest = NULL) { - versions <- library_versions(lib) - dir.create(dest, recursive = TRUE, showWarnings = FALSE) - index <- list( - run_id = env_chr("GITHUB_RUN_ID"), - created_at = now_utc(), - r_version = paste( - R.version$major, - sub("[.].*$", "", R.version$minor), - sep = "." - ), - platform = R.version$platform, - count = length(versions), - packages = unname(Map( - function(p, v) list(package = p, version = unname(v)), - names(versions), - versions - )) - ) - write_json(index, file.path(dest, "lib.json")) - if (!is.null(index_dest)) { - dir.create(index_dest, recursive = TRUE, showWarnings = FALSE) - file.copy( - file.path(dest, "lib.json"), - file.path(index_dest, "lib.json"), - overwrite = TRUE - ) - } - if (length(versions) == 0) { - inform("Nothing to pack: ", lib, " holds no installed packages") - return(character()) - } - members <- tempfile("members-") - writeLines(names(versions), members) - on.exit(unlink(members)) - tarball <- file.path(dest, "library.tar") - status <- system2( - "tar", - # Quoted: system2() quotes the command, but not the arguments. - shQuote(c("-cf", tarball, "-C", lib, "-T", members)) - ) - if (!identical(as.integer(status), 0L) || !file.exists(tarball)) { - inform("Packing ", lib, " failed; this run contributes no prebuilt library") - unlink(tarball) - return(character()) - } - inform( - "Packed ", - length(versions), - " package(s) into ", - basename(tarball), - " (", - format( - structure(file.size(tarball), class = "object_size"), - units = "auto" - ), - ")" - ) - names(versions) -} - -# The packages of `lib` a caller may still want: everything not already -# installed there, and nothing this session has loaded -- a package must never -# be overwritten underneath the driver that is using it. -missing_from <- function(lib, wanted) { - setdiff( - unique(unlist(wanted, use.names = FALSE)), - c(list.dirs(lib, full.names = FALSE, recursive = FALSE), loadedNamespaces()) - ) -} - -# Extract `take` out of a packed library into `lib`, and report what landed. -unpack_library <- function(tarball, lib, take) { - dir.create(lib, recursive = TRUE, showWarnings = FALSE) - members <- tempfile("members-") - writeLines(take, members) - on.exit(unlink(members)) - # A member the index promised but the tar does not hold makes tar exit - # non-zero after extracting the rest; what actually landed is the answer, so - # the status is not consulted. Its complaints still are, when fewer packages - # land than were asked for: "no space left on device" and "member not found" - # are the same shortfall here and the same silence before. - err <- tempfile(fileext = ".err") - on.exit(unlink(err), add = TRUE) - system2( - "tar", - # Quoted: system2() quotes the command, but not the arguments. - shQuote(c("-xf", tarball, "-C", lib, "-T", members)), - stdout = NULL, - stderr = err - ) - got <- intersect(take, list.dirs(lib, full.names = FALSE, recursive = FALSE)) - if (length(got) < length(take)) { - detail <- stderr_tail(err) - inform(sprintf( - "Prebuilt: tar produced %d of %d requested package(s)%s", - length(got), - length(take), - if (nzchar(detail)) paste0("; tar said: ", detail) else "" - )) - } - # A half-extracted package directory is worse than none: drop anything - # without a DESCRIPTION and let pak install it properly. - broken <- got[!file.exists(file.path(lib, got, "DESCRIPTION"))] - if (length(broken) > 0) { - unlink(file.path(lib, broken), recursive = TRUE) - inform("Prebuilt: discarded ", length(broken), " incomplete package(s)") - } - setdiff(got, broken) -} - -# Unpack a library artifact this job already has on disk -- in practice this -# run's own preflight library, handed to the shards through the workflow. -# -# This is the reuse that pays on the very first run: without it every shard -# rebuilds from source what the preflight of the same run compiled minutes -# earlier, once per shard. -restore_local_library <- function(dir, lib, wanted) { - tarball <- file.path(dir, "library.tar") - if (!nzchar(dir) || !file.exists(tarball)) { - return(character()) - } - take <- missing_from(lib, wanted) - index <- file.path(dir, "lib.json") - if (file.exists(index)) { - have <- vapply( - read_json(index)$packages, - function(e) e$package, - character(1) - ) - take <- intersect(take, have) - } - if (length(take) == 0) { - return(character()) - } - got <- unpack_library(tarball, lib, take) - inform( - "Prebuilt: restored ", - length(got), - " package(s) from this run's preflight" - ) - got -} - -# Unpack what earlier runs already built into `lib`, taking only the packages -# in `wanted` that are not there yet. -# -# The plan named the donor runs, youngest first, and which packages each one -# is good for; a younger donor always wins, and a donor that has nothing left -# to give is never downloaded. Whatever lands here is still handed to pak -# afterwards: the point is to skip *building* what has not changed, not to -# skip resolving it. -restore_prebuilt <- function(plan, lib, wanted) { - donors <- plan$prebuilt$runs %||% list() - if (length(donors) == 0 || length(unlist(wanted)) == 0) { - return(character()) - } - dir.create(lib, recursive = TRUE, showWarnings = FALSE) - restored <- character() - for (donor in donors) { - run_id <- as.character(donor$run_id) - take <- intersect( - unlist(donor$packages, use.names = FALSE), - missing_from(lib, wanted) - ) - if (length(take) == 0) { - next - } - inform("Prebuilt: fetching ", length(take), " package(s) from run ", run_id) - started <- Sys.time() - dir <- fetch_artifact(run_id, "revdep2-lib", tempfile("prebuilt-")) - tarball <- if (is.null(dir)) NULL else file.path(dir, "library.tar") - if (is.null(tarball) || !file.exists(tarball)) { - # Three different failures used to share one message, and the one that - # actually happened -- the artifact was there, the download or the - # unpacking was not -- was the one the message denied. - inform(sprintf( - "Prebuilt: nothing restored from run %s after %.0f s; %s", - run_id, - as.numeric(difftime(Sys.time(), started, units = "secs")), - if (is.null(dir)) { - "see the reason above" - } else { - paste0( - "the artifact unpacked to ", - paste(list.files(dir), collapse = ", "), - ", which has no library.tar" - ) - } - )) - unlink(dir, recursive = TRUE) - next - } - got <- unpack_library(tarball, lib, take) - inform(sprintf( - "Prebuilt: restored %d of %d package(s) from run %s in %.0f s (%s tarball)", - length(got), - length(take), - run_id, - as.numeric(difftime(Sys.time(), started, units = "secs")), - format_bytes(file.size(tarball)) - )) - unlink(dir, recursive = TRUE) - restored <- c(restored, got) - } - restored -} - -# ------------------------------------------------------- the last report ---- - -# The packages an earlier report says were not ok, read from the `revdep/` -# directory in the checkout rather than from a run's artifacts. -# -# That directory is the durable record: the collector commits it back to the -# checked branch, and before this workflow existed `revdepcheck::cloud_check()` -# wrote the same four files there. So both generations are read: `manifest.json` -# when this workflow wrote it (it says exactly which result each package got), -# and otherwise revdepcheck's own markdown -- one `# ()` -# heading per package in problems.md and failures.md, plus the "Failed to -# check" table in README.md, which is where a package that produced no -# comparison at all is named. -report_packages <- function(dir) { - none <- list(packages = character(), source = "") - if (!nzchar(dir %||% "") || !dir.exists(dir)) { - return(none) - } - manifest <- file.path(dir, "manifest.json") - if (file.exists(manifest)) { - entries <- tryCatch(read_json(manifest), error = function(e) NULL) - if (length(entries) > 0) { - names <- vapply(entries, function(e) e$package %||% "", character(1)) - results <- vapply(entries, function(e) e$result %||% "", character(1)) - take <- nzchar(names) & vapply(results, needs_recheck, logical(1)) - if (any(take)) { - return(list( - packages = sort(unique(names[take])), - source = "manifest.json" - )) - } - } - } - headings <- function(file) { - if (!file.exists(file)) { - return(character()) - } - lines <- grep("^# ", readLines(file, warn = FALSE), value = TRUE) - trimws(sub("^# ([^ (]+).*$", "\\1", lines)) - } - failed_table <- function(file) { - if (!file.exists(file)) { - return(character()) - } - lines <- readLines(file, warn = FALSE) - from <- grep("^#+ +Failed to check", lines) - if (length(from) == 0) { - return(character()) - } - after <- grep("^#+ ", lines) - after <- after[after > from[[1]]] - block <- lines[seq( - from[[1]], - if (length(after) > 0) after[[1]] - 1L else length(lines) - )] - cells <- trimws(sub( - "^\\|([^|]*)\\|.*$", - "\\1", - grep("^\\|", block, value = TRUE) - )) - # Drop the header and the alignment row; what is left is one package each. - cells[nzchar(cells) & cells != "package" & !grepl("^:?-+:?$", cells)] - } - packages <- unique(c( - headings(file.path(dir, "problems.md")), - headings(file.path(dir, "failures.md")), - failed_table(file.path(dir, "README.md")) - )) - list( - packages = sort(packages[nzchar(packages)]), - source = "problems.md, failures.md, README.md" - ) -} - -# --------------------------------------------------------- measured timings -- - -# What a run measured about itself, so the next plan can stop guessing. -# -# The collector writes one `timings.json` per run -- a row per package (how -# long its checks actually took here, next to what CRAN reports for it) and a -# row per shard (job, install and check minutes, next to what the plan -# predicted) -- and publishes it as the small `revdep2-timings` artifact, -# separate from the report the way `revdep2-lib-index` is separate from the -# library: a plan reads it without downloading anything else. -# -# Three constants come out of it, each a median over what actually happened, -# and each NULL when the runs measured nothing usable -- the caller keeps its -# default then. `runs` is youngest first; a younger measurement of a package -# wins, and the constants pool every row there is. - -read_timings <- function(dir) { - if (!nzchar(dir %||% "")) { - return(NULL) - } - path <- file.path(dir, "timings.json") - if (!file.exists(path)) { - return(NULL) - } - tryCatch(read_json(path), error = function(e) NULL) -} - -# Seconds one check of a package took here, per package, youngest run first. -measured_check_seconds <- function(runs) { - out <- stats::setNames(numeric(), character()) - for (run in runs) { - for (row in run$packages %||% list()) { - seconds <- suppressWarnings(as.numeric(row$seconds %||% NA)) - if ( - is.null(row$package) || - row$package %in% names(out) || - is.na(seconds) || - seconds <= 0 - ) { - next - } - out[[row$package]] <- seconds - } - } - out -} - -# The medians the plan's cost model runs on: -# check_scale - check seconds here per second CRAN reports (T_total); -# these runners are not CRAN's machines -# setup_minutes - per-shard fixed cost, from job start to the driver's -# first line: the runner image, R, TinyTeX, the artifacts -# install_seconds - marginal cost of one more dependency in a shard's union -calibration <- function(runs) { - scales <- numeric() - setups <- numeric() - installs <- numeric() - packages <- 0L - shards <- 0L - for (run in runs) { - for (row in run$packages %||% list()) { - seconds <- suppressWarnings(as.numeric(row$seconds %||% NA)) - cran <- suppressWarnings(as.numeric(row$t_total %||% NA)) - packages <- packages + 1L - if (!is.na(seconds) && !is.na(cran) && seconds > 0 && cran > 0) { - scales <- c(scales, seconds / cran) - } - } - for (row in run$shards %||% list()) { - shards <- shards + 1L - job <- suppressWarnings(as.numeric(row$job_minutes %||% NA)) - script <- suppressWarnings(as.numeric(row$script_minutes %||% NA)) - if (!is.na(job) && !is.na(script) && job >= script) { - setups <- c(setups, job - script) - } - minutes <- suppressWarnings(as.numeric(row$install_minutes %||% NA)) - count <- suppressWarnings(as.numeric(row$install_packages %||% NA)) - if (!is.na(minutes) && !is.na(count) && count > 0) { - installs <- c(installs, minutes * 60 / count) - } - } - } - median_or_null <- function(x) { - if (length(x) == 0) NULL else unname(stats::median(x)) - } - list( - check_scale = median_or_null(scales), - setup_minutes = median_or_null(setups), - install_seconds = median_or_null(installs), - packages = packages, - shards = shards, - runs = length(runs) - ) -} - -# How long each shard's *job* took, by shard index. The driver can time itself, -# but not the minutes before it starts -- the runner image, R, pandoc, TinyTeX, -# the artifact downloads. That gap is exactly the per-shard setup cost the plan -# charges for every extra shard, so it is measured here, in the one job that -# runs after all the shards and can still ask the API about them. -run_shard_job_minutes <- function(run_id) { - empty <- stats::setNames(numeric(), character()) - if (!gh_ok() || !nzchar(gh_repo())) { - return(empty) - } - rows <- character() - for (page in 1:5) { - got <- gh_lines( - "api", - sprintf( - "repos/%s/actions/runs/%s/jobs?per_page=100&page=%d", - gh_repo(), - run_id, - page - ), - "--jq", - ".jobs[] | [.name, .started_at, .completed_at] | @tsv" - ) - got <- if (is.null(got)) character() else got[nzchar(got)] - rows <- c(rows, got) - if (length(got) < 100) { - break - } - } - out <- empty - stamp <- function(x) { - as.POSIXct(x, format = "%Y-%m-%dT%H:%M:%SZ", tz = "UTC") - } - for (row in rows) { - fields <- strsplit(row, "\t", fixed = TRUE)[[1]] - if (length(fields) < 3 || !grepl("^shard [0-9]+ ", fields[[1]])) { - next - } - index <- sub("^shard ([0-9]+) .*$", "\\1", fields[[1]]) - from <- stamp(fields[[2]]) - to <- stamp(fields[[3]]) - if (is.na(from) || is.na(to) || to < from) { - next - } - minutes <- as.numeric(difftime(to, from, units = "mins")) - # A re-run reports the later attempt last; it is the one that produced the - # artifact the collector is reading. - out[[index]] <- minutes - } - out -} - -# ------------------------------------------------------------ CRAN metadata -- - -cran_repo <- function() { - env_chr("REVDEP2_CRAN_MIRROR", "https://cloud.r-project.org") -} - -# available.packages() for the canonical CRAN mirror, fetched once. -cran_db <- local({ - db <- NULL - function() { - if (is.null(db)) { - inform("Fetching CRAN package metadata from ", cran_repo()) - db <<- utils::available.packages( - repos = cran_repo(), - filters = c("CRAN", "duplicates") - ) - } - db - } -}) - -base_packages <- function() { - rownames(utils::installed.packages(priority = c("base", "recommended"))) -} - -# The packages that must be installed to check `packages`: their hard -# dependencies and direct suggests, plus the recursive hard dependencies of -# all of those. One list per element of `packages`. -install_closure <- function(packages, db) { - direct <- tools::package_dependencies(packages, db = db, which = "most") - pool <- unique(unlist(direct, use.names = FALSE)) - pool <- intersect(pool, rownames(db)) - recursive <- tools::package_dependencies( - pool, - db = db, - which = "strong", - recursive = TRUE - ) - lapply(direct, function(deps) { - deps <- intersect(deps, rownames(db)) - full <- unique(c(deps, unlist(recursive[deps], use.names = FALSE))) - sort(setdiff(intersect(full, rownames(db)), base_packages())) - }) -} - -# The same set, cut into installable pieces: chunks of at most `size`, -# ordered so that every strong dependency inside the set is installed before -# the package that needs it. -# -# One pak call for a few thousand refs means one resolution of a few thousand -# refs, and that is where the preflight of run 31270092803 died: ten minutes -# inside pak, not one install started, then the runner was shut down. The -# resolution is the part that does not degrade gracefully, so it is the part -# that is kept small -- each chunk resolves against a library where its -# dependencies already are. -# -# It also changes what a failure costs. Whatever earlier chunks installed is -# on disk and is skipped on the next attempt, so a chunk that dies costs a -# chunk; and the log says which one, which a single opaque call never could. -# -# The size is a trade, and 100 was too far towards small. A chunk pays one -# resolution whether or not it installs anything: run 31930350338's preflight -# logged `80 pkgs + 214 deps: kept 294 [44s]` for a chunk that built nothing at -# all. At 100, the 4406-package universe is 45 chunks and something like half -# an hour of resolution before a single build starts -- on the critical path, -# since every shard waits for the preflight. At 400 it is 12 chunks. A chunk -# that dies costs four times as much to redo, which is the price; the counter -# is that the resolution which killed run 31270092803 was a few thousand refs, -# and 400 is an order of magnitude below that. -# -# Ordering is on strong dependencies only. Suggests are in the set because a -# revdep's *check* needs them, not its installation, and they are what makes -# the graph cyclic -- ordering on them would order on nothing. -install_chunks <- function(pkgs, db, size = 400) { - pkgs <- unique(pkgs) - if (length(pkgs) == 0) { - return(list()) - } - deps <- tools::package_dependencies(pkgs, db = db, which = "strong") - index <- stats::setNames(seq_along(pkgs), pkgs) - needs <- lapply(pkgs, function(p) { - unname(index[intersect(deps[[p]] %||% character(), pkgs)]) - }) - done <- logical(length(pkgs)) - order <- integer() - repeat { - ready <- which(!done & vapply(needs, function(d) all(done[d]), logical(1))) - if (length(ready) == 0) { - break - } - done[ready] <- TRUE - order <- c(order, ready) - } - # A cycle, or a dependency this index cannot describe, leaves packages that - # never become ready. They go last, together, for pak to sort out among - # themselves -- which is what it was doing for the whole set before. - order <- c(order, which(!done)) - unname(split(pkgs[order], ceiling(seq_along(order) / size))) -} - -# Install one chunked set, reporting each chunk as it lands. Returns TRUE when -# every chunk succeeded; a caller that cares which packages are missing asks -# the library, not this. -# Run `fun` in a child R process and give up on it after `timeout_seconds`. -# -# Nothing this workflow calls out to has a time limit of its own, and in run -# 31276552027 that cost a job: `pak::pkg_install()` on chunk 21 never returned, -# and the preflight sat at one busy core and flat memory for 76 minutes until -# it was cancelled by hand. There is no loop to break there -- the call simply -# does not come back -- so the only thing that helps is a clock. -# -# Two details make this work where `tryCatch` and `setTimeLimit` do not. The -# child inherits this process's stdout and stderr, so pak's progress still -# streams to the job log with nobody draining a pipe; and it is killed with -# `kill_tree()`, because what wedges is pak's *own* subprocess, a grandchild, -# which outlives a plain kill of its parent. -# -# It also isolates the calls from each other. Chunks 14 and 20 of that run had -# already failed with "error in pak subprocess" before 21 hung, and one wedged -# pak subprocess used to poison every call after it; now each one starts a -# fresh R and a fresh pak. -# -# callr comes with rcmdcheck, and the preflight installs it outright. Where it -# is missing there is no way to bound anything, so the call is made inline -- -# the old behaviour, announced rather than silent. -run_with_timeout <- function(fun, args = list(), timeout_seconds, label = "") { - if (!requireNamespace("callr", quietly = TRUE)) { - inform(label, ": callr is not installed, running without a time limit") - value <- NULL - message <- tryCatch( - { - value <- do.call(fun, args) - "" - }, - error = function(e) conditionMessage(e) - ) - return(list( - ok = !nzchar(message), - timed_out = FALSE, - message = message, - value = value - )) - } - process <- callr::r_bg( - fun, - args = args, - stdout = "", - stderr = "2>&1", - supervise = TRUE - ) - process$wait(timeout = timeout_seconds * 1000) - if (process$is_alive()) { - process$kill_tree() - process$wait(timeout = 10000) - return(list( - ok = FALSE, - timed_out = TRUE, - message = sprintf( - "no output and no result after %s; killed", - format_duration(timeout_seconds) - ) - )) - } - value <- NULL - message <- tryCatch( - { - value <- process$get_result() - "" - }, - # callr reports a child's failure wrapped in its own condition, and the - # wrapper is three lines of scaffolding around the one line that says what - # broke -- which is the line that ends up in depfail.json. - error = function(e) conditionMessage(e$parent %||% e) - ) - list( - ok = !nzchar(message), - timed_out = FALSE, - message = message, - value = value - ) -} - -format_duration <- function(seconds) { - if (seconds < 90) { - sprintf("%.0f s", seconds) - } else if (seconds < 90 * 60) { - sprintf("%.0f min", seconds / 60) - } else { - # A shard runs for hours, and "217 min left" is a number the reader has to - # divide before it means anything. - sprintf("%.1f h", seconds / 3600) - } -} - -# ---------------------------------------------------- pak's repositories ---- - -# The repository set, resolved once and pinned. -# -# pak reads `getOption("repos")` and adds the Bioconductor repositories to it -# the moment something needs them -- and its metadata database is keyed on the -# set. In run 31282820357 the first Bioconductor package landed in chunk 11 of -# 45; the set went from 1 repository to 6 and the database from 7 files to 9, -# the rebuilt database came back empty ("0 B in 9 files", parsed in 20 ms -# rather than 9 s), and from chunk 12 on pak could not find a single package -# on CRAN. Not vctrs -- all 4406 of them. -# -# Installing in chunks is what made that reachable: 45 short-lived pak -# processes each re-read the database from disk, so the set changing under one -# of them poisons all the rest. Resolving the set here, before the first -# install, is what stops it from changing at all. -pinned_repos <- local({ - repos <- NULL - function() { - if (is.null(repos)) { - repos <<- tryCatch( - { - got <- pak::repo_get(bioc = TRUE) - stats::setNames(got$url, got$name) - }, - error = function(e) { - inform("Could not resolve the repository set: ", conditionMessage(e)) - getOption("repos") - } - ) - inform( - "Repositories pinned: ", - length(repos), - " (", - paste(names(repos), collapse = ", "), - ")" - ) - } - repos - } -}) - -# ------------------------------------------------------ pak's metadata db ---- - -# Packages that must be in any CRAN snapshot. If pak cannot see these, it -# cannot see anything, and what follows is not a dependency problem. -metadata_probe <- function() { - strsplit(env_chr("REVDEP2_METADATA_PROBE", "vctrs,cli,R6"), ",")[[1]] -} - -metadata_timeout_seconds <- function() { - env_num("REVDEP2_METADATA_TIMEOUT_MINUTES", 10) * 60 -} - -# How many of those packages pak can actually see, or -1 when it could not be -# asked. `meta_list()` is the low-level view of the database itself, so a -# broken one answers immediately instead of being reported as a dependency -# that cannot be solved. -# -# It has to run in a fresh process. pak keeps the parsed database in the -# memory of its own subprocess, so a session that already loaded a good one -# goes on reporting health that is no longer on disk -- which is why the break -# in that run only surfaced at the *next* chunk. -metadata_found <- function() { - run <- run_with_timeout( - function(repos, probe) { - options(repos = repos) - nrow(pak::meta_list(pkg = probe)) - }, - args = list(repos = pinned_repos(), probe = metadata_probe()), - timeout_seconds = metadata_timeout_seconds(), - label = "pak metadata probe" - ) - if (!isTRUE(run$ok)) { - # Not the same thing as an empty database, and saying so matters: in run - # 31303054725 `/tmp` filled, callr could no longer start R, and every - # probe from then on failed to run at all -- reported as "pak sees 0 of - # 3", which reads like the database being empty and is a completely - # different problem. - inform("Could not ask pak what it can see: ", run$message) - return(-1L) - } - as.integer(run$value %||% 0L) -} - -# Delete the metadata database and fetch it again. -# -# `meta_clean(force = TRUE)` is the part that matters. pak's own repair -- -# `meta_update()` alone -- is what produced "0 B in 9 files": it re-validated -# the broken files, found them unchanged, and left the empty database in -# place. Only deleting it first gets a good one back. -metadata_repair <- function() { - run_with_timeout( - function(repos) { - options(repos = repos) - pak::meta_clean(force = TRUE) - pak::meta_update() - invisible(NULL) - }, - args = list(repos = pinned_repos()), - timeout_seconds = metadata_timeout_seconds(), - label = "pak metadata rebuild" - ) -} - -# Assess pak's metadata database, and rebuild it at most once per job. -# -# Returns "ok", "repaired" or "broken". Once per job is deliberate: a database -# that is still empty after a clean rebuild is not a stale cache, and clearing -# it in a loop would spend the job's minutes hiding that. -ensure_metadata <- local({ - repaired <- FALSE - function(where = "") { - prefix <- if (nzchar(where)) paste0(where, ": ") else "" - wanted <- length(metadata_probe()) - found <- metadata_found() - if (found >= wanted) { - return("ok") - } - # A probe that could not be run says nothing about the database, and - # clearing it on that evidence would spend the one rebuild on a machine - # problem -- which is exactly what would have happened when R could no - # longer start. - if (found < 0) { - inform(prefix, "the state of the metadata database is unknown") - return("unknown") - } - if (repaired) { - inform(sprintf( - "%spak still sees %d of %d probe packages after a rebuild; not clearing again", - prefix, - max(found, 0L), - wanted - )) - return("broken") - } - repaired <<- TRUE - inform(sprintf( - "%spak sees %d of %d packages that must exist -- its metadata database is unusable; clearing and rebuilding it once", - prefix, - max(found, 0L), - wanted - )) - rebuild <- metadata_repair() - if (!isTRUE(rebuild$ok)) { - inform(prefix, "the rebuild failed: ", rebuild$message) - return("broken") - } - found <- metadata_found() - if (found >= wanted) { - inform(prefix, "the metadata database is usable again") - return("repaired") - } - inform(sprintf( - "%sstill %d of %d after the rebuild; the repositories themselves are not answering", - prefix, - max(found, 0L), - wanted - )) - "broken" - } -}) - -# -------------------------------------------------- system requirements ---- - -sysreqs_timeout_seconds <- function() { - env_num("REVDEP2_SYSREQS_TIMEOUT_MINUTES", 20) * 60 -} - -# The system requirements of packages that were unpacked rather than installed. -# -# pak installs system requirements for the packages *it* installs. Everything -# restored from a library tarball -- this run's preflight library, an earlier -# run's donor -- it never sees, so their apt packages are never resolved: 170 -# of a shard's 436 dependencies arrived that way in run 31282820357. It -# usually survives, because something else pulls the same apt package in or -# the runner image already carries it; when it does not, a restored binary -# cannot load its shared library, and a shard has no load test to catch that. -# -# So the library is asked directly rather than the install list, which is what -# makes this cover donor libraries from earlier runs too -- their apt state was -# never recorded anywhere, and pak can still read what they left behind. -# -# `sysreqs_check_installed()` says what is missing and which packages want it, -# which is worth printing either way; `sysreqs_fix_installed()` installs it. -ensure_sysreqs <- function(lib = NULL, label = "") { - prefix <- if (nzchar(label)) paste0(label, ": ") else "" - - survey <- function(what) { - run <- run_with_timeout( - function(repos, lib) { - options(repos = repos) - got <- pak::sysreqs_check_installed(library = lib) - absent <- !got$installed - list( - total = nrow(got), - missing = as.character(got$system_package[absent]), - wanted_by = vapply( - got$packages[absent], - function(p) paste(p, collapse = ", "), - character(1) - ) - ) - }, - args = list(repos = pinned_repos(), lib = lib), - timeout_seconds = sysreqs_timeout_seconds(), - label = paste0(prefix, what) - ) - if (!isTRUE(run$ok)) { - inform(prefix, "could not check system requirements: ", run$message) - return(NULL) - } - run$value - } - - before <- survey("system requirements survey") - if (is.null(before)) { - return(invisible(NULL)) - } - if (length(before$missing) == 0) { - inform(sprintf( - "%sall %d system requirement(s) of the installed library are present", - prefix, - before$total - )) - return(invisible(character())) - } - inform(sprintf( - "%s%d of %d system requirement(s) are missing: %s", - prefix, - length(before$missing), - before$total, - paste( - sprintf("%s (%s)", before$missing, before$wanted_by), - collapse = "; " - ) - )) - - fixed <- run_with_timeout( - function(repos, lib) { - options(repos = repos) - pak::sysreqs_fix_installed(library = lib) - invisible(NULL) - }, - args = list(repos = pinned_repos(), lib = lib), - timeout_seconds = sysreqs_timeout_seconds(), - label = paste0(prefix, "system requirements install") - ) - if (!isTRUE(fixed$ok)) { - inform(prefix, "installing them failed: ", fixed$message) - return(invisible(before$missing)) - } - - after <- survey("system requirements re-survey") - still <- if (is.null(after)) before$missing else after$missing - if (length(still) == 0) { - inform(sprintf( - "%sinstalled %d missing system package(s)", - prefix, - length(before$missing) - )) - } else { - inform(sprintf( - "%s%d system package(s) are still missing: %s", - prefix, - length(still), - paste(still, collapse = ", ") - )) - } - invisible(still) -} - -# The system requirements of the packages the shard is about to *check*. -# -# `ensure_sysreqs()` above reads the installed library, which is the right -# question for dependencies and the wrong one for the revdeps themselves: a -# shard installs each package's dependency closure and never the package, so -# the revdep under test is never in that library. `R CMD check` builds it from -# its tarball, and nothing has resolved its `SystemRequirements` -- not -# `PKG_SYSREQS`, which covers what pak installs, and not -# `sysreqs_fix_installed()`, which covers what is on disk. -# -# Libra is the case that found this. It declares `SystemRequirements: gsl`, -# `pak::pkg_sysreqs("Libra")` resolves it to `libgsl0-dev` without difficulty, -# and nobody asked: the check failed to compile `LBLasso.c` under both versions -# with `fatal error: gsl/gsl_vector.h: No such file or directory`, which the -# report then recorded as a package that fails to install rather than as a -# runner that could not build it. -# -# Only the missing ones are installed. `sysreqs_list_system_packages()` says -# what is already there, including what other packages *provide* -- a virtual -# package satisfies a dependency just as a real one does -- so a shard whose -# requirements the image already carries runs no apt at all. -ensure_check_sysreqs <- function(packages, label = "") { - prefix <- if (nzchar(label)) paste0(label, ": ") else "" - if (length(packages) == 0) { - return(invisible(character())) - } - - run <- run_with_timeout( - function(repos, packages) { - options(repos = repos) - wanted <- unique(unlist( - pak::pkg_sysreqs(packages)$packages$system_packages, - use.names = FALSE - )) - have <- pak::sysreqs_list_system_packages() - present <- unique(c( - have$package, - unlist(have$provides, use.names = FALSE) - )) - list(wanted = wanted, missing = setdiff(wanted, present)) - }, - args = list(repos = pinned_repos(), packages = packages), - timeout_seconds = sysreqs_timeout_seconds(), - label = paste0(prefix, "check system requirements survey") - ) - if (!isTRUE(run$ok)) { - inform( - prefix, - "could not resolve the checked packages' system requirements: ", - run$message - ) - return(invisible(NULL)) - } - - if (length(run$value$missing) == 0) { - inform(sprintf( - "%sall %d system requirement(s) of the %d package(s) to check are present", - prefix, - length(run$value$wanted), - length(packages) - )) - return(invisible(character())) - } - inform(sprintf( - "%s%d of %d system requirement(s) of the packages to check are missing: %s", - prefix, - length(run$value$missing), - length(run$value$wanted), - paste(run$value$missing, collapse = ", ") - )) - - # apt directly rather than through pak: `sysreqs_fix_installed()` reads the - # library, and these packages are not in it. Failure is reported and not - # fatal -- the check will fail either way, and it will say why more clearly - # than this can. - sudo <- if (identical(Sys.info()[["effective_user"]], "root")) { - character() - } else { - "sudo" - } - status <- suppressWarnings(system2( - if (length(sudo)) "sudo" else "apt-get", - c( - if (length(sudo)) "apt-get", - "-o", - "DPkg::Lock::Timeout=300", - "install", - "-y", - "--no-install-recommends", - run$value$missing - ) - )) - if (!identical(status, 0L)) { - inform(prefix, "apt-get exited ", status, "; the checks run anyway") - return(invisible(run$value$missing)) - } - inform(sprintf( - "%sinstalled %d system package(s) for the packages to check", - prefix, - length(run$value$missing) - )) - invisible(character()) -} - -# One pak install, bounded. Separate from install_in_chunks() because the -# per-package retry after a failed chunk needs exactly the same treatment: it -# is the same call, one package at a time, and it used to be just as -# unbounded. -pak_install <- function( - pkgs, - lib = NULL, - upgrade = FALSE, - timeout_seconds, - label = "" -) { - run_with_timeout( - function(pkgs, lib, upgrade, repos) { - # The pin travels into every child: an option set in the parent is not - # inherited, and a child that resolves its own repository set is a child - # that can change it. - options(repos = repos) - if (is.null(lib)) { - pak::pkg_install(pkgs, ask = FALSE, upgrade = upgrade) - } else { - pak::pkg_install(pkgs, lib = lib, ask = FALSE, upgrade = upgrade) - } - invisible(NULL) - }, - args = list( - pkgs = pkgs, - lib = lib, - upgrade = upgrade, - repos = pinned_repos() - ), - timeout_seconds = timeout_seconds, - label = label - ) -} - -# `deadline` is the wall clock past which no further chunk is started. It is -# not a second timeout but the answer to a different question: the per-chunk -# limit stops one call from running for ever, and this stops 45 of them from -# adding up past what the job has. What is left unattempted is named, and the -# caller still gets to pack and publish what did install. -install_in_chunks <- function( - chunks, - lib = NULL, - upgrade = FALSE, - label = "", - timeout_seconds = install_timeout_seconds(), - deadline = NULL -) { - ok <- TRUE - prefix <- if (nzchar(label)) paste0(label, ": ") else "" - for (i in seq_along(chunks)) { - if (!is.null(deadline) && Sys.time() > deadline) { - inform(sprintf( - "%sthe install deadline passed; %d of %d chunk(s) not attempted", - prefix, - length(chunks) - i + 1L, - length(chunks) - )) - return(FALSE) - } - started <- Sys.time() - label <- sprintf("%schunk %d/%d", prefix, i, length(chunks)) - run <- pak_install( - chunks[[i]], - lib = lib, - upgrade = upgrade, - timeout_seconds = timeout_seconds, - label = label - ) - # A chunk that fails may have failed because pak could not see the - # repositories at all, which is a different thing from a package that will - # not install -- and it is the state that, left alone, fails every chunk - # after it too. Only a rebuild that actually changed something earns the - # retry; a healthy database means the failure was real. - if ( - !run$ok && identical(ensure_metadata(sub(": $", "", prefix)), "repaired") - ) { - inform(prefix, "retrying chunk ", i, " against the rebuilt metadata") - run <- pak_install( - chunks[[i]], - lib = lib, - upgrade = upgrade, - timeout_seconds = timeout_seconds, - label = label - ) - } - if (!run$ok) { - inform(prefix, "chunk ", i, " failed: ", run$message) - } - ok <- ok && run$ok - inform(sprintf( - "%schunk %d/%d (%d packages) %s after %.1f min", - prefix, - i, - length(chunks), - length(chunks[[i]]), - if (run$ok) { - "installed" - } else if (run$timed_out) { - "timed out" - } else { - "failed" - }, - as.numeric(difftime(Sys.time(), started, units = "mins")) - )) - } - ok -} - -install_timeout_seconds <- function() { - env_num("REVDEP2_INSTALL_TIMEOUT_MINUTES", 20) * 60 -} - -# Fingerprint of the *versions* of everything a check installs, from CRAN -# metadata. Two runs whose fingerprints agree resolved the same dependency -# tree, so an old-version check result can be carried from one to the other. -dep_fingerprint <- function(deps, db) { - if (length(deps) == 0) { - return("empty") - } - lines <- sort(paste(deps, db[deps, "Version"])) - path <- tempfile("fingerprint-") - on.exit(unlink(path)) - writeLines(lines, path) - unname(tools::md5sum(path)) -} - -# ------------------------------------------------------------ result labels -- - -# Collapse an rcmdcheck comparison (or a failure shim) into the one word the -# manifest, the collector and the retry selection agree on. -# ok -- no new problems -# newly_broken -- the dev version introduces problems the CRAN version lacks -# failed -- the check could not run to a comparable end (install -# failure, timeout, error before/around the check) -# depfail -- dependencies could not be installed, check not attempted -# deferred -- shard deadline hit before this package was checked -# error -- the shard driver itself broke on this package -# missing -- the plan named it, no shard ever reported it: the job died -# (assigned by the collector, never by a shard) -classify_status <- function(status, new_issues) { - if (status %in% c("+", "-")) { - if (identical(status, "-") && new_issues > 0) "newly_broken" else "ok" - } else { - "failed" - } -} - -# Did `R CMD check` refuse to start because a package this one needs is not -# installed? -# -# That stage is fatal: check reports the one error and stops, in two or three -# seconds, having looked at nothing. When it happens to *both* halves -- and it -# always does, since the two libraries differ only in igraph -- the pair -# compares clean, `compare_checks()` returns `+`, and the verdict is `ok`. -# -# 55 of run 31930350338's 984 `ok` results were that: every one of them a -# package whose Bioconductor dependencies are not on CRAN and so were never -# installed. `SEMgraph` is the clearest -- `1E 0W 0N` on both sides in three -# seconds, reported `ok`, while being genuinely broken by a dev change nobody -# saw because the check never ran. -# -# The check log is the only place this is visible; the status is `+` like any -# other agreeing pair. -aborted_on_dependencies <- function(check) { - errors <- check$errors %||% character() - length(errors) > 0 && - any(grepl("^checking package dependencies [.]{3} ERROR", errors)) -} - -# The packages the aborted check named, for the manifest message: the whole -# point of the class is that a reader can see *what* was missing without -# opening the artifact. -missing_dependencies <- function(check) { - errors <- check$errors %||% character() - hit <- grep( - "^checking package dependencies [.]{3} ERROR", - errors, - value = TRUE - ) - if (length(hit) == 0) { - return(character()) - } - lines <- strsplit(hit[[1]], "\n", fixed = TRUE)[[1]] - # `Packages required but not available:` puts them on the next line, quoted; - # `Package required but not available: 'x'` puts one on the same line. - named <- grep("required but not available", lines) - if (length(named) == 0) { - return(character()) - } - block <- paste( - lines[seq(named[[1]], min(named[[1]] + 1L, length(lines)))], - collapse = " " - ) - unique(gsub( - "[‘’']", - "", - regmatches( - block, - gregexpr("[‘'][^’']+[’']", block) - )[[1]] - )) -} - -# What a status that `classify_status()` can only call "failed" actually means, -# in words. A reader of the summary has to tell "broken under the dev version" -# apart from "broken everywhere" without opening the artifact, and the one word -# in the manifest cannot say it. Empty for the two statuses that compared. -status_message <- function(status) { - switch( - status, - "+" = , - "-" = "", - "i-" = "installs against the CRAN version, fails to install against the dev version", - "i+" = "fails to install against either version", - "t-" = "check timed out against the dev version, not against the CRAN version", - "t+" = "check timed out against both versions", - sprintf("check comparison inconclusive (status `%s`)", status) - ) -} - -needs_recheck <- function(result) { - !(result %in% c("ok")) -} diff --git a/.github/workflows/revdep2/watch-resources.sh b/.github/workflows/revdep2/watch-resources.sh deleted file mode 100755 index eae38b0..0000000 --- a/.github/workflows/revdep2/watch-resources.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bash -# What the machine has left, sampled while the work is still running. -# -# A job that is killed rather than failed takes its post-steps with it: when -# the runner receives a shutdown signal, `if: always()` steps never run, the -# artifact is never uploaded, and the only record that survives is what was -# already streamed to the log. So the numbers that explain such a death have -# to be emitted *during* the work, not after it -- which is what this does. -# -# Usage: -# watch-resources.sh once [label] one sample, labelled -# watch-resources.sh watch [seconds] [label] a sample every `seconds`, -# until the process is killed -# watch-resources.sh oom what the kernel killed, if -# anything -- the difference -# between "out of memory" and -# "the host went away" -# -# Every sample also goes to $RESOURCE_LOG when that is set, so a job that does -# reach its upload step carries the series in its artifact too. -# -# In `watch` mode the label may move: with $RESOURCE_PHASE_FILE set, each sample -# reads its first line and uses that instead of the fixed argument. The sampler -# outlives any one phase of the work -- that is the point of it -- so a label -# fixed when it starts is wrong for everything after. The preflight labelled -# half an hour of load-testing `installing` because of exactly this. - -set -u - -mode="${1:-once}" - -emit() { - printf '[resources] %s\n' "$1" - if [ -n "${RESOURCE_LOG:-}" ]; then - mkdir -p "$(dirname "${RESOURCE_LOG}")" 2> /dev/null || true - printf '%s\n' "$1" >> "${RESOURCE_LOG}" || true - fi -} - -# One line: clock, memory, swap, disk on the two filesystems that fill up -# here, load, and the three largest processes -- which is what names the -# thing that grew just before the machine stopped answering. -sample() { - local label="${1:-}" - local mem disk load top - if [ -n "${RESOURCE_PHASE_FILE:-}" ] && [ -r "${RESOURCE_PHASE_FILE}" ]; then - label=$(head -n 1 "${RESOURCE_PHASE_FILE}" 2> /dev/null) || label="${1:-}" - fi - - mem=$(awk ' - /^MemTotal:/ { total = $2 } - /^MemAvailable:/ { avail = $2 } - /^SwapTotal:/ { swap_total = $2 } - /^SwapFree:/ { swap_free = $2 } - END { - printf "mem %.1f/%.1fG used, %.1fG available; swap %.1f/%.1fG used", - (total - avail) / 1048576, total / 1048576, avail / 1048576, - (swap_total - swap_free) / 1048576, swap_total / 1048576 - }' /proc/meminfo) - - # `/mnt` is the runner's large ephemeral disk, and `/` the one everything - # here actually writes to; duplicates collapse, so naming a path twice or - # naming one that does not exist costs nothing. - disk=$(df -BG --output=target,avail \ - / /mnt /tmp "${RUNNER_TEMP:-/tmp}" "${TMPDIR:-/tmp}" 2> /dev/null | - awk 'NR > 1 && !seen[$1]++ { printf "%s %s free; ", $1, $2 }' | - sed 's/; $//') - - load=$(cut -d ' ' -f 1-3 < /proc/loadavg) - - top=$(ps -eo rss=,comm= --sort=-rss 2> /dev/null | - head -n 3 | - awk '{ printf "%s %.1fG; ", $2, $1 / 1048576 }' | - sed 's/; $//') - - emit "$(date -u +%H:%M:%S)${label:+ ${label}} -- ${mem}; ${disk}; load ${load}; largest: ${top}" -} - -case "${mode}" in - once) - sample "${2:-}" - ;; - watch) - interval="${2:-30}" - label="${3:-}" - while true; do - sample "${label}" - sleep "${interval}" - done - ;; - oom) - # `dmesg` needs privileges on a stock kernel; on a hosted runner sudo is - # passwordless, and where it is not, saying so beats saying nothing. - if kills=$(sudo -n dmesg 2> /dev/null | - grep -iE 'out of memory|oom-kill|oom_reaper|killed process' | - tail -n 5) && [ -n "${kills}" ]; then - emit "the kernel reports out-of-memory kills:" - printf '[resources] %s\n' "${kills}" - elif [ -n "${kills:-}" ]; then - emit "no out-of-memory kills in dmesg" - else - emit "no out-of-memory kills in dmesg (or dmesg is not readable here)" - fi - ;; - *) - echo "usage: watch-resources.sh {once [label]|watch [seconds] [label]|oom}" >&2 - exit 2 - ;; -esac diff --git a/.github/workflows/revdep4.yaml b/.github/workflows/revdep4.yaml index 887d632..aef198f 100644 --- a/.github/workflows/revdep4.yaml +++ b/.github/workflows/revdep4.yaml @@ -10,7 +10,7 @@ # simultaneity instead of isolating it: a package's old and new halves run # SEQUENTIALLY, and the lost concurrency is won back ACROSS packages -- a # custom bash work queue runs REVDEPX_WORKERS packages at once, each check in -# its own container (see revdep4/queue.sh). +# its own container (see .github/actions/revdep4/queue.sh in cynkra/cynkratemplate). # # The queue is two-ended over the shard's heaviest-first package list: one # worker eats from the heavy end, so the longest checks start first and are @@ -52,16 +52,18 @@ # the manifest, the baseline for later runs, and the timings the # next plan calibrates from. # -# The scripts live in `.github/workflows/revdepx/` (once shared with the -# retired concurrent-pair sibling, revdep3, whose PR was closed unmerged); -# artifacts, manifest schema and the universe image lineage are unchanged, +# The scripts live in `.github/actions/revdepx/` of cynkra/cynkratemplate +# (once shared with the retired concurrent-pair sibling, revdep3, whose PR +# was closed unmerged), and each job reaches them through the +# `revdep-scripts` action; artifacts, manifest schema and the universe image lineage are unchanged, # so history from the sibling's live runs still serves as baselines and # timings. # # Results land in artifacts: # revdepx-report the merged report -- fetch with # `gh run download --name revdepx-report` -# or ./.github/workflows/revdepx/fetch.sh +# or .github/actions/revdepx/fetch.sh , +# run from a checkout of cynkra/cynkratemplate # revdepx-baseline old-version results, read back by later runs of # either workflow as a second opinion beside their # fresh old checks, while the revdep's version, our @@ -84,7 +86,7 @@ on: workflow_dispatch: inputs: ref: - description: "Branch, tag, or commit SHA to check (the tree must contain the revdepx scripts); default: the dispatched ref" + description: "Branch, tag, or commit SHA to check; default: the dispatched ref" type: string default: "" packages: @@ -250,6 +252,10 @@ jobs: actions: read steps: + # The scripts are served with the kit's actions, not copied into the repository. + - name: Locate the revdep scripts + uses: cynkra/cynkratemplate/.github/actions/revdep-scripts@main + - name: Check out the ref under test uses: actions/checkout@v6 with: @@ -281,7 +287,7 @@ jobs: GH_TOKEN: ${{ github.token }} OUT: ${{ runner.temp }}/plan.json run: | - Rscript ./.github/workflows/revdepx/plan.R + Rscript "${REVDEPX_DIR}/plan.R" shell: bash - name: Upload the shard plan @@ -314,6 +320,10 @@ jobs: packages: write steps: + # The scripts are served with the kit's actions, not copied into the repository. + - name: Locate the revdep scripts + uses: cynkra/cynkratemplate/.github/actions/revdep-scripts@main + - name: Check out the ref under test uses: actions/checkout@v6 with: @@ -339,7 +349,7 @@ jobs: run: | set -eu repo=$(printf '%s' "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]') - ref=$(./.github/workflows/revdepx/base-image.sh \ + ref=$("${REVDEPX_DIR}/base-image.sh" \ "${REVDEPX_R_VERSION}" "ghcr.io/${repo}/revdepx-base" | tail -n 1) echo "Base image: ${ref}" echo "image=${ref}" >> "${GITHUB_OUTPUT}" @@ -433,6 +443,10 @@ jobs: packages: write steps: + # The scripts are served with the kit's actions, not copied into the repository. + - name: Locate the revdep scripts + uses: cynkra/cynkratemplate/.github/actions/revdep-scripts@main + - name: Check out the ref under test uses: actions/checkout@v6 with: @@ -475,7 +489,7 @@ jobs: - name: Start the resource sampler run: | mkdir -p "${RUNNER_TEMP}/universe-out" - watch=./.github/workflows/revdepx/watch-resources.sh + watch="${REVDEPX_DIR}/watch-resources.sh" "${watch}" once "before the universe build" RESOURCE_LOG="${RUNNER_TEMP}/universe-out/resources.log" \ RESOURCE_PHASE_FILE="${RUNNER_TEMP}/universe-out/phase" \ @@ -531,8 +545,8 @@ jobs: - name: Report what the build consumed if: always() run: | - ./.github/workflows/revdepx/watch-resources.sh once "after the universe build" - ./.github/workflows/revdepx/watch-resources.sh oom + "${REVDEPX_DIR}/watch-resources.sh" once "after the universe build" + "${REVDEPX_DIR}/watch-resources.sh" oom shell: bash - name: Upload the universe build report @@ -625,6 +639,10 @@ jobs: DEADLINE_MINUTES: ${{ vars.REVDEPX_DEADLINE_MINUTES || '300' }} steps: + # The scripts are served with the kit's actions, not copied into the repository. + - name: Locate the revdep scripts + uses: cynkra/cynkratemplate/.github/actions/revdep-scripts@main + - name: Check out the ref under test uses: actions/checkout@v6 with: @@ -710,7 +728,7 @@ jobs: - name: Start the resource sampler run: | mkdir -p "${RUNNER_TEMP}/results" - watch=./.github/workflows/revdepx/watch-resources.sh + watch="${REVDEPX_DIR}/watch-resources.sh" "${watch}" once "shard ${SHARD} before the prepare phase" RESOURCE_LOG="${RUNNER_TEMP}/results/resources.log" \ "${watch}" watch 30 "shard ${SHARD}" & @@ -730,7 +748,7 @@ jobs: env: PHASE: prepare run: | - Rscript ./.github/workflows/revdepx/shard.R + Rscript "${REVDEPX_DIR}/shard.R" shell: bash # The checks run in three slices, each followed by an upload: a @@ -751,10 +769,10 @@ jobs: # Streamed from inside the step -- a sampler backgrounded in an # earlier step stops reaching the job log when that step ends, and # the checks are where the minutes (and the memory) actually go. - ./.github/workflows/revdepx/watch-resources.sh watch 60 "shard ${SHARD} checks (1/3)" & + "${REVDEPX_DIR}/watch-resources.sh" watch 60 "shard ${SHARD} checks (1/3)" & sampler=$! trap 'kill "${sampler}" 2> /dev/null || true' EXIT - Rscript ./.github/workflows/revdepx/shard.R + Rscript "${REVDEPX_DIR}/shard.R" shell: bash - name: Upload the shard results (1 of 3) @@ -773,10 +791,10 @@ jobs: PHASE: check CHECK_SLICE: 2/3 run: | - ./.github/workflows/revdepx/watch-resources.sh watch 60 "shard ${SHARD} checks (2/3)" & + "${REVDEPX_DIR}/watch-resources.sh" watch 60 "shard ${SHARD} checks (2/3)" & sampler=$! trap 'kill "${sampler}" 2> /dev/null || true' EXIT - Rscript ./.github/workflows/revdepx/shard.R + Rscript "${REVDEPX_DIR}/shard.R" shell: bash - name: Upload the shard results (2 of 3) @@ -795,10 +813,10 @@ jobs: PHASE: check CHECK_SLICE: 3/3 run: | - ./.github/workflows/revdepx/watch-resources.sh watch 60 "shard ${SHARD} checks (3/3)" & + "${REVDEPX_DIR}/watch-resources.sh" watch 60 "shard ${SHARD} checks (3/3)" & sampler=$! trap 'kill "${sampler}" 2> /dev/null || true' EXIT - Rscript ./.github/workflows/revdepx/shard.R + Rscript "${REVDEPX_DIR}/shard.R" shell: bash # Before the final upload, so the last sample and the OOM verdict ride @@ -806,8 +824,8 @@ jobs: - name: Report what the shard consumed if: always() run: | - ./.github/workflows/revdepx/watch-resources.sh once "shard ${SHARD} after the checks" - ./.github/workflows/revdepx/watch-resources.sh oom + "${REVDEPX_DIR}/watch-resources.sh" once "shard ${SHARD} after the checks" + "${REVDEPX_DIR}/watch-resources.sh" oom df -BG / "${RUNNER_TEMP}" 2>/dev/null || true shell: bash @@ -855,6 +873,10 @@ jobs: actions: read steps: + # The scripts are served with the kit's actions, not copied into the repository. + - name: Locate the revdep scripts + uses: cynkra/cynkratemplate/.github/actions/revdep-scripts@main + - name: Check out the ref under test uses: actions/checkout@v6 with: @@ -920,7 +942,7 @@ jobs: BASELINE_OUT: ${{ runner.temp }}/baseline TIMINGS_OUT: ${{ runner.temp }}/timings run: | - Rscript ./.github/workflows/revdepx/collect.R + Rscript "${REVDEPX_DIR}/collect.R" shell: bash - name: Upload the report diff --git a/.github/workflows/revdep4/README.md b/.github/workflows/revdep4/README.md deleted file mode 100644 index d48d364..0000000 --- a/.github/workflows/revdep4/README.md +++ /dev/null @@ -1,200 +0,0 @@ -# `revdep4` — the sequential-halves queue engine - -`.github/workflows/revdep4.yaml` is built on the core in -[`../revdepx/`](../revdepx/README.md). -It checks every reverse dependency of this package twice — -once against the CRAN release, once against the dev version — -inside a prebuilt universe image, -running a package's two halves one after the other -and winning the lost parallelism back *across* packages -with a bash work queue. -(A sibling *pair* engine, `revdep3`, -ran the halves simultaneously, one container each; -it was retired with its unmerged PR, -and its runs remain valid baseline and timing history.) -The shared core is documented in `../revdepx/README.md`; -this file covers only what `REVDEPX_ENGINE=queue` changes. - -## Why sequential halves - -`revdep2` ran a package's old and new checks -as two processes on the same host at the same moment. -They run the same code at the same time, -so every per-machine singleton is a collision waiting to happen — -and one collision actually happened: -both halves drew the same PSOCK port -(seeded RNG plus simultaneous start), -and two packages were reported newly broken with nothing wrong with them. -The `R_PARALLEL_PORT` fix was per-mechanism: -it repaired the one collision that had already produced false verdicts, -and left the shared `TMPDIR`, shared caches, shared locks — -everything else that is one-per-machine — to be discovered the same way. -Checking two instances of the same package at once -is simply not a mode anything in the R toolchain promises to support. - -This engine removes the class instead of its members: -at no moment do two checks of the same package coexist. -The old half runs, finishes, and then the new half runs. -What simultaneity bought — parallel hardware use — is bought back one level up: -with `W` workers, `W` *different* packages are in flight at once, -which no shared assumption anywhere touches. - -## Two containers per package, sequentially - -Each half still runs in a container of its own (`check-half.sh`), -so different packages are isolated from each other -exactly as thoroughly as the two halves of one package are: -own network namespace, own PID space, own `/tmp`, -own memory cap, identical in-container paths. -That is strictly stronger than `revdep2`, -which only ever separated the two halves' ports — -cross-*package* interference on one host was never addressed at all. -It also makes the two halves' logs trivially comparable: -inside the container the paths are the same for both, -so the log diff no longer needs path neutralisation to be honest. - -## The queue - -`shard.R` writes one line per runnable package — -name, tarball, timeout, weight — -sorted heaviest first, and hands the file to `queue.sh`. -The list is consumed from both ends: - -- worker 1, the *heavy lane*, claims from the top — - the heaviest package still unclaimed; -- workers 2..W, the *light lanes*, claim from the bottom — - lightest first; -- claims move two cursors in a state file under an `flock`, - and the queue is empty when the cursors cross. - -The rationale is the classic LPT observation. -The heaviest checks decide when the shard finishes: -discovered last, one of them runs alone -after everything else has drained, -and the shard's wall clock is everything-else *plus* the straggler. -Started first, the straggler runs for its hour -while the swarm of cheap packages drains in parallel from the other end, -and the two ends meet in the middle. -One heavy lane is enough: -a second one only helps -when the two heaviest packages together outlast the rest of the shard combined, -and the plan's shard balancing already makes that configuration unlikely. -The light lanes' high turnover is itself useful — -progress lines keep coming, -and a deadline that arrives mid-shard -cuts cheap packages, not expensive ones. - -Every claim is recorded in `claimed.log` -(epoch, worker, lane, line number, package), -and `queue-state.json` summarises the run -(claims, completed, fallback lines, deferred-at-exit) -for `shard.R`'s accounting. -A claimed package can never vanish silently: -`compare-one.R` writes the manifest line; -if it crashes it is re-run in `--error` mode; -if that fails too, `queue.sh` appends a hardcoded JSON error line; -and a worker that dies outright is caught by a final sweep -that reconciles `claimed.log` against the manifest. - -## What "heavy" means - -The queue's order key is **expected check seconds** — -measured on this infrastructure in prior `revdepx` runs -(youngest first), -else CRAN's reported `T_total` scaled by the self-calibrating factor -the collector fits from measured runs, -else the cohort median. -Package size and the number or size of dependencies -are deliberately *not* in the key: -those are install-time costs, -and installation is amortised into the shared universe image, -where it costs a package's check nothing. -Dependency *count* enters only the depfail screen -(a package whose strong closure is incomplete is never queued), -and dealing affinity is moot under a shared image. -For shard sizing, the plan prices a queue package -at both halves' seconds — twice the per-half estimate. - -## The stored old result: always a second opinion, never a substitute - -Both halves always run fresh. -Sequential halves would make skipping the old check tempting — -it costs real wall clock — -and the pinned container platform would even make the substitution -far safer than when `revdep2` tried and abandoned it -(76 of one run's 78 `newly_broken` verdicts were false, -compared across different machines, paths and CRAN snapshots). -The temptation is declined on purpose: -a fresh old check is the only result -whose provenance this run fully controls. - -What the stored result does instead is stand *beside* the fresh one. -Where the plan certifies an earlier run's old result as comparable — -same revdep version, our CRAN version, container R series, -`base_image` tag and dependency fingerprint, within the age cap — -`compare-one.R` records whether the fresh old check reproduced it -(`baseline_agrees` on the manifest line) -and prints any disagreement as drift. -A disagreement under pinned conditions is a signal worth reading: -a flaky test, a moved system library, or this harness getting it wrong. -Old `revdep2` baselines lack the `base_image` field -and are never offered — a deliberate firewall, -since their checks ran on a different platform entirely. - -## Workers, memory, deadline - -- `REVDEPX_WORKERS` (default: `nproc`) sets the lane count. -- Each check container gets a memory cap: - `REVDEPX_MEMORY_PER_CHECK` (6g by default — - a deliberate overcommit of the 15.6 GiB runner across 4 workers, - because checks rarely peak together - and the derived cap OOM-killed compilers during Stan/TMB installs), - falling back to `(MemTotal − 2 GiB) / workers`, floored at 2 GiB, - when cleared; - exported to `check-half.sh` as `REVDEPX_MEMORY`. - A hungry check OOM-kills its own container, not the runner, - and the 2 GiB headroom keeps docker and the runner agent responsive — - the sequential engine has no need for revdep2's `nice`. -- Before claiming, a worker prices the candidate - at `weight_minutes × 60 × 1.3` - (the plan's estimate plus the shard driver's usual trailing margin) - and stops claiming once that no longer fits before the deadline. - The lanes stop independently: - the heavy lane prices the heaviest remaining package and may stop early, - while the light lanes keep draining the cheap end. - The very first claim across all workers is always attempted, - so a mis-budgeted shard still makes progress - instead of repeating its mistake on every retry. - Unclaimed lines are the deferred tail; - `shard.R` writes their `deferred` manifest lines - when the queue returns, dedup'd against what earlier slices reported. - -## Files - -- `queue.sh` — the two-ended work queue described above. - Test seams: `REVDEPX_CHECK_HALF` and `REVDEPX_COMPARE_ONE` - override the collaborators' paths, - so the queue mechanics run against stubs. -- `compare-one.R` — per-package driver: - reads both halves back (`read_side`), - keeps a surviving half when its partner failed (`keep_side`), - compares (`compare_halves`), salvages check output and the log diff, - and appends the manifest line under the manifest lock. - All shared logic comes from `../revdepx/util.R` and `../revdepx/compare.R`. - -## Continuity with the retired `revdep3` - -The retired pair engine shared everything but the engine: -the `revdepx-*` artifact family and names, -`plan.json`, manifest and `timings.json` schemas -(the queue's `t_old`/`t_new` are true per-half seconds, -where the pair engine recorded the pair's shared wall clock — -`timings.json` carries an `engine` field -so calibration never mixes the two setups' overheads), -the baseline artifact, -the universe image on GHCR and the base image under it, -the comparison code, -and the report committed to the `revdep` directory. -`REVDEPX_WORKFLOWS` says whose histories the plan scans, -so old `revdep3` runs still feed baselines and timings -while they remain within the age bounds. diff --git a/.github/workflows/revdep4/compare-one.R b/.github/workflows/revdep4/compare-one.R deleted file mode 100644 index 7541446..0000000 --- a/.github/workflows/revdep4/compare-one.R +++ /dev/null @@ -1,311 +0,0 @@ -# Read one reverse dependency's two check halves back, compare them, and -# append the package's manifest line. -# -# queue.sh runs the halves (check-half.sh, one container each) and then this, -# one process per package. The split is deliberate: the bash side owns -# claiming, containers and clocks; everything that needs R -- parsing a -# 00check.log, `compare_checks()`, the manifest line -- lives here, in the -# same shared code (revdepx/compare.R) the pair engine uses, so the two -# engines cannot drift in how they read a check. -# -# A crash here is one package's problem: queue.sh catches the nonzero exit, -# re-runs this script with --error to write a plain error line, and falls -# back to a hardcoded JSON line if even that fails. So: exit 0 on every -# handled path, and let a genuine R error exit nonzero rather than papering -# over it. -# -# Arguments, all as `--key value` pairs: -# -# --name package name (required) -# --manifest manifest.ndjson to append to (required; the lock is -# .lock, shared with every other writer) -# --workdir the package's check workdir, holding old/ and new/ -# --version version actually checked (the tarball's); "" keeps the -# plan's -# --pkgs-dir results pkgs/ directory, for rds and salvage output -# (default: pkgs/ next to the manifest) -# --baseline-dir baseline artifact directory; "" or absent = no baseline -# --plan plan.json, for the package's metadata (default: plan.json) -# --shard shard index (default: 0) -# --timeout per-half timeout in seconds, for the timeout messages -# --t-old old half's wall seconds as queue.sh measured them -# --t-new new half's wall seconds -# --cran-version what the prepare phase installed as the old igraph -# --dev-version ... and as the new one -# --error MSG write an `error` line carrying MSG and do nothing else -# (queue.sh's second rung, before its printf fallback) - -script_dir <- dirname(sub( - "--file=", - "", - grep("^--file=", commandArgs(), value = TRUE) -)) -revdepx_dir <- file.path(script_dir, "..", "revdepx") -source(file.path(revdepx_dir, "util.R")) -source(file.path(revdepx_dir, "compare.R")) - -# ------------------------------------------------------------------- argv --- - -args <- commandArgs(trailingOnly = TRUE) -opt <- list() -i <- 1L -while (i <= length(args)) { - key <- args[[i]] - if (!startsWith(key, "--") || i == length(args)) { - stop("expected --key value pairs, got: ", key) - } - opt[[substring(key, 3L)]] <- args[[i + 1L]] - i <- i + 2L -} -req <- function(key) { - value <- opt[[key]] %||% "" - if (!nzchar(value)) { - stop("--", key, " is required") - } - value -} -num_or_na <- function(value) { - value <- suppressWarnings(as.numeric(value %||% "")) - if (length(value) == 1 && !is.na(value)) value else NA -} - -name <- req("name") -manifest_path <- req("manifest") -shard_index <- as.integer(num_or_na(opt$shard)) -if (is.na(shard_index)) { - shard_index <- 0L -} -t_old <- num_or_na(opt[["t-old"]]) -t_new <- num_or_na(opt[["t-new"]]) -timeout_sec <- num_or_na(opt$timeout) -if (is.na(timeout_sec)) { - timeout_sec <- 0 -} -baseline_dir <- opt[["baseline-dir"]] %||% "" -pkgs_dir <- opt[["pkgs-dir"]] %||% file.path(dirname(manifest_path), "pkgs") -our_cran_version <- opt[["cran-version"]] %||% "" -our_dev_version <- opt[["dev-version"]] %||% "" - -# ------------------------------------------------------------------ entry --- - -# The package's plan metadata: version, level, weight, fingerprint, the -# baseline flag. Read defensively -- in --error mode this script may be -# running precisely because something around it is broken, and a line with -# defaults beats no line. -plan_pkg <- tryCatch( - { - plan <- read_json(opt$plan %||% "plan.json") - found <- NULL - for (s in plan$shards) { - if (identical(as.integer(s$index %||% -1L), shard_index)) { - for (p in s$packages) { - if (identical(p$name, name)) { - found <- p - } - } - } - } - found - }, - error = function(e) NULL -) - -entry <- tryCatch( - # Signature per the revdepx contract: (name, plan_pkg, shard_index), a NULL - # plan_pkg yielding plan-less defaults. - manifest_entry_defaults(name, plan_pkg, shard_index), - error = function(e) NULL -) -if (!is.list(entry)) { - # compare.R could not build the entry (or returned a surprise): fall back - # to the same defaults shard.R initialises, so the line still carries every - # schema field. - entry <- list( - package = name, - version = plan_pkg$version %||% "", - level = plan_pkg$level %||% 0L, - shard = shard_index, - weight_minutes = plan_pkg$weight_minutes %||% 0, - t_total = plan_pkg$t_total %||% 0, - dep_fingerprint = plan_pkg$dep_fingerprint %||% NA, - baseline_planned = isTRUE(plan_pkg$baseline), - baseline_agrees = NA, - result = "deferred", - status = "", - status_old = "", - status_new = "", - new_issues = 0L, - t_old = NA, - t_new = NA, - old_checked_at = NA, - message = "" - ) -} -if (nzchar(opt$version %||% "")) { - entry$version <- opt$version -} - -apply_updates <- function(entry, updates) { - # compare.R's functions return named lists of manifest-field updates; merge - # only fields the entry knows, so an unexpected return shape cannot corrupt - # the line. - if (is.list(updates)) { - keep <- intersect(names(updates), names(entry)) - entry[keep] <- updates[keep] - } - entry -} - -finish <- function(entry) { - # Queue semantics for the time fields, whatever the updates said: t_old and - # t_new are the true per-half wall seconds queue.sh measured around each - # check-half.sh call. The halves run one after the other here, unlike the - # pair engine, whose halves share one wall clock and one number. - entry$t_old <- t_old - entry$t_new <- t_new - # One write per package, at the very end, under the manifest lock (inside - # write_manifest_line): the line appears whole or not at all, and queue.sh - # covers "not at all". - write_manifest_line(entry, manifest_path, our_cran_version, our_dev_version) - quit(save = "no", status = 0L) -} - -# ------------------------------------------------------------- error mode --- - -if (!is.null(opt$error)) { - # queue.sh's second rung: something already went wrong, usually this very - # script a moment ago, so do the one thing that must not be skipped -- - # account for the package -- and nothing that could fail the same way - # again. - entry$result <- "error" - entry$message <- opt$error - finish(entry) -} - -# ------------------------------------------------------------- the halves --- - -work_dir <- req("workdir") - -# read_side() (compare.R) parses a half back from its container's output: -# an rcmdcheck object, or an error condition, with the duration/timed_out/ -# last_step attributes attached. Both halves always ran -- a stored old -# result is only ever a second opinion, applied inside compare_halves(). -new <- read_side(work_dir, "new", name, timeout_sec, t_new) -old <- read_side(work_dir, "old", name, timeout_sec, t_old) - -# A half that produced a result is kept even when its partner did not -- -# the same dance as the pair engine, through the same functions. -if (inherits(new, "error")) { - salvage_side(work_dir, pkgs_dir, name, "new") - if (!inherits(old, "error")) { - entry <- apply_updates( - entry, - keep_side(work_dir, pkgs_dir, name, "old", old) - ) - } else { - salvage_side(work_dir, pkgs_dir, name, "old") - } - entry <- apply_updates(entry, check_failure(name, "new", new)) - finish(entry) -} -if (inherits(old, "error")) { - salvage_side(work_dir, pkgs_dir, name, "old") - entry <- apply_updates( - entry, - keep_side(work_dir, pkgs_dir, name, "new", new) - ) - entry <- apply_updates(entry, check_failure(name, "old", old)) - finish(entry) -} - -# ------------------------------------------------------------- comparison --- - -# compare.R owns everything from here to the verdict: the rds saves, the -# second-opinion drift check against the stored old result the plan offered, -# compare_checks(), the both-halves-depfail guard, classify_status(). One -# code path for both engines is the point of the extraction: the two cannot -# drift in how they read a check. -entry <- apply_updates( - entry, - compare_halves( - name, - old, - new, - pkgs_dir = pkgs_dir, - baseline_dir = if (nzchar(baseline_dir)) baseline_dir else NULL, - baseline_planned = isTRUE(entry$baseline_planned) - ) -) - -inform(sprintf( - "%s: %s (old %s, new %s, old %ds + new %ds)", - name, - entry$result, - entry$status_old, - entry$status_new, - round(t_old), - round(t_new) -)) - -# -------------------------------------------------------------- artifacts --- - -# Salvage before the manifest line, but never at its expense: a failure while -# copying or diffing must not cost the package its line. -tryCatch( - { - if (identical(entry$result, "ok")) { - # Nothing kept but the two rds; the whole check tree goes. - unlink(work_dir, recursive = TRUE) - } else { - keep <- file.path(pkgs_dir, name, "new-check") - new_rcheck <- file.path(work_dir, "new", paste0(name, ".Rcheck")) - copy_check_output(new_rcheck, keep) - old_log <- file.path( - work_dir, - "old", - paste0(name, ".Rcheck"), - "00check.log" - ) - new_log <- file.path(new_rcheck, "00check.log") - if (file.exists(old_log) && file.exists(new_log)) { - diff <- check_diff(name, old_log, new_log, work_dir) - writeLines(diff, file.path(keep, "00check.diff")) - # Into the job log too, collapsed: what the dev version changed about - # this package, in the package's own words, without downloading an - # artifact. print_group writes to stderr, where Actions still folds - # it; queue.sh keeps stdout for data. - diff_max_lines <- env_num("REVDEPX_DIFF_MAX_LINES", 200) - print_group( - sprintf( - "%s: old vs new check log (%d line diff)", - name, - length(diff) - ), - if (length(diff) == 0) { - # A `newly_broken` whose logs are identical once the paths and - # timings are out is this harness getting it wrong; worth a line. - "The two logs are identical apart from paths and stage timings." - }, - head(diff, diff_max_lines), - if (length(diff) > diff_max_lines) { - sprintf( - "[%d more lines; the whole diff is 00check.diff in the shard artifact]", - length(diff) - diff_max_lines - ) - } - ) - } - unlink(work_dir, recursive = TRUE) - } - }, - error = function(e) { - inform( - name, - ": salvage failed (", - conditionMessage(e), - "); the manifest line survives" - ) - } -) - -finish(entry) diff --git a/.github/workflows/revdep4/queue.sh b/.github/workflows/revdep4/queue.sh deleted file mode 100755 index 36a4b78..0000000 --- a/.github/workflows/revdep4/queue.sh +++ /dev/null @@ -1,508 +0,0 @@ -#!/usr/bin/env bash -# Drain one shard's check queue: every reverse dependency old half then new -# half, one container per check, several packages at once. -# -# Usage: -# queue.sh -# -# The queue file has one package per line, tab-separated, sorted -# heaviest-first by the caller (shard.R): -# -# name tarball-abs-path timeout_sec weight_minutes -# -# The list is consumed from both ends at once. Worker 1 takes the next line -# from the top -- the heaviest package still unclaimed -- and the remaining -# workers take from the bottom, lightest first. The heaviest checks are the -# ones that decide when the shard finishes: started late, one of them becomes -# the straggler discovered last, running alone after everything else has -# drained (the classic LPT lesson). Started first, it runs for its hour while -# the swarm of cheap packages drains in parallel from the other end, and the -# two ends meet in the middle. One heavy lane is enough: a second one only -# helps when the two heaviest packages together outlast everything else -# combined, and the plan's shard balancing already makes that unlikely. -# -# A claim is two cursor integers in one state file, moved under an flock -- -# a few arithmetic operations, so the critical section is tiny and no check -# ever holds the lock. Every claim lands in claimed.log (epoch, worker, lane, -# line number, package) for forensics. -# -# Deadline: before claiming, a worker prices the candidate at -# weight_minutes * 60 * 1.3 -- the plan's own estimate plus the shard -# driver's trailing margin -- and stops claiming once now plus that crosses -# the deadline. The candidate stays unclaimed; shard.R writes it a `deferred` -# line when it reads the manifest back. The very first claim across all -# workers is always attempted, so a mis-budgeted shard still makes progress -# instead of repeating its mistake on every retry (the same rule as the shard -# driver's own out_of_time()); the flag for it lives in the locked state file -# because the exemption must be claimed exactly once. The lanes defer -# independently, which is the point of having two: the heavy lane prices the -# heaviest remaining package and may stop while the light lanes, pricing the -# lightest, keep draining. -# -# Per package: check-half.sh old, then check-half.sh new -- both halves -# always run fresh; a stored old result is compare-one.R's second opinion, -# never a substitute -- then compare-one.R, which parses, -# compares, salvages and appends the package's manifest line itself under -# .lock. Every failure past a claim still produces a manifest -# line: compare-one.R crashing gets a second run with --error; that failing -# gets a hardcoded printf JSON line; a worker dying outright is caught by the -# final sweep, which writes lines for anything claimed but unreported. A -# claimed package can never vanish silently. -# -# Environment: -# REVDEPX_WORKERS worker count (default: nproc) -# REVDEPX_IMAGE image ref, required by check-half.sh -# REVDEPX_MEMORY_PER_CHECK per-container memory cap; default computed as -# (MemTotal - 2 GiB) / workers, floored at 2 GiB; -# exported to check-half.sh as REVDEPX_MEMORY -# BASELINE_DIR, PLAN, SHARD forwarded to compare-one.R -# REVDEPX_OUR_CRAN_VERSION, REVDEPX_OUR_DEV_VERSION -# what the prepare phase installed; forwarded to -# compare-one.R and stamped on fallback lines -# REVDEPX_CHECK_HALF, REVDEPX_COMPARE_ONE -# override the two collaborators' paths (tests) -# plus whatever check-half.sh forwards into the container (_R_CHECK_* and -# friends), which this script passes through untouched. -# -# Leaves in : claimed.log and queue-state.json for shard.R's -# accounting, one directory per claimed package, and .queue/ with the cursor -# state. stdout is reserved for data and currently carries nothing; -# everything human goes to stderr, so the two streams can be captured -# separately, as in load-test.sh. Always exits 0 once draining has started: -# which packages failed, and how, is the manifest's business, not the -# shell's. - -set -u - -if [ "$#" -ne 6 ]; then - echo "usage: queue.sh " \ - " " >&2 - exit 2 -fi - -queue_file=$1 -workdir=$2 -old_lib=$3 -new_lib=$4 -manifest=$5 -deadline=$6 - -# The claim protocol is flock or nothing: without it the cursor updates race -# and two workers can check the same package. CI runners always have it; a -# laptop that does not gets a loud refusal here instead of a silently -# unserialized queue. -if ! command -v flock > /dev/null 2>&1; then - echo "queue.sh: flock is not available; the claim protocol cannot run safely" >&2 - exit 2 -fi - -if [ ! -r "${queue_file}" ]; then - echo "queue.sh: queue file not readable: ${queue_file}" >&2 - exit 2 -fi -case ${deadline} in - '' | *[!0-9]*) - echo "queue.sh: deadline must be epoch seconds, got: ${deadline}" >&2 - exit 2 - ;; -esac - -# The two collaborators, resolved once into variables so that a test can -# point them at stubs and exercise the queue mechanics without docker or R -# package checks. -script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -CHECK_HALF=${REVDEPX_CHECK_HALF:-${script_dir}/../revdepx/check-half.sh} -COMPARE_ONE=${REVDEPX_COMPARE_ONE:-${script_dir}/compare-one.R} - -workers=${REVDEPX_WORKERS:-} -if ! [[ ${workers} =~ ^[0-9]+$ ]] || (( workers < 1 )); then - workers=$(nproc 2> /dev/null || echo 4) -fi - -# One memory cap for every check container, sized so that the workers -# together leave the host 2 GiB for docker, this script and the runner agent. -# The floor matters more than the division: below 2 GiB real packages die -# compiling, so the cap stays at 2 GiB even where workers x 2 GiB -# oversubscribes the machine -- then the kernel OOM-kills one container, -# which check-half.sh records against that one package, rather than this -# script quietly checking less in parallel than it was asked to. -if [ -n "${REVDEPX_MEMORY_PER_CHECK:-}" ]; then - export REVDEPX_MEMORY=${REVDEPX_MEMORY_PER_CHECK} -else - mem_total_kib=$(awk '/^MemTotal:/ { print $2 }' /proc/meminfo 2> /dev/null || true) - if [[ ${mem_total_kib:-} =~ ^[0-9]+$ ]]; then - head_room_kib=$((2 * 1024 * 1024)) - floor_kib=$((2 * 1024 * 1024)) - avail_kib=$((mem_total_kib - head_room_kib)) - if (( avail_kib < 0 )); then avail_kib=0; fi - per_kib=$((avail_kib / workers)) - if (( per_kib < floor_kib )); then per_kib=${floor_kib}; fi - export REVDEPX_MEMORY="$((per_kib / 1024))m" - else - echo "[queue] cannot read MemTotal; REVDEPX_MEMORY stays ${REVDEPX_MEMORY:-unset}" >&2 - fi -fi - -# Queue bookkeeping. claimed.log and queue-state.json sit at the top of the -# work directory, where shard.R's accounting expects them; the mutable state -# hides in .queue/, which no CRAN package can be named after. -state_dir=${workdir}/.queue -state_file=${state_dir}/state -state_lock=${state_dir}/state.lock -done_count=${state_dir}/done -fallback_count=${state_dir}/fallback -claimed_log=${workdir}/claimed.log -manifest_lock=${manifest}.lock -pkgs_dir=$(dirname -- "${manifest}")/pkgs - -# One compiler cache for the whole shard, handed to every check container. -# Shard-wide rather than per package, because two packages that share a -# LinkingTo dependency compile the same headers, and because it costs nothing -# beyond what the halves already give: each package compiles twice here, so -# the second half of the first package is already a hit. Under `.` like the -# queue's own state, which no CRAN package can be named after. -# -# Export, because check-half.sh is a separate process; it mounts the directory -# and does the PATH work. Set REVDEPX_CCACHE_DIR= (empty) to turn the whole -# thing off and compile everything twice, as before. -if [ -z "${REVDEPX_CCACHE_DIR+x}" ]; then - REVDEPX_CCACHE_DIR=${workdir}/.ccache -fi -export REVDEPX_CCACHE_DIR - -mkdir -p "${workdir}" "${state_dir}" "${pkgs_dir}" -if [ -n "${REVDEPX_CCACHE_DIR}" ]; then - mkdir -p "${REVDEPX_CCACHE_DIR}" -fi -: > "${done_count}" -: > "${fallback_count}" -: > "${claimed_log}" -touch "${manifest}" - -# The queue, read once into memory; the workers inherit the array. Line -# numbers are positions in this file, so it must not have interior blank -# lines (shard.R writes none); a trailing one is tolerated. -mapfile -t queue_lines < "${queue_file}" -while (( ${#queue_lines[@]} > 0 )) && [ -z "${queue_lines[-1]//[[:space:]]/}" ]; do - unset 'queue_lines[-1]' -done -total=${#queue_lines[@]} -width=${#total} - -# Cursors: head at the heavy end, tail at the light end, and the -# claimed-anything flag for the first-claim exemption. Queue empty when the -# head passes the tail. -printf '1 %d 0\n' "${total}" > "${state_file}" - -started_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ') -started_epoch=${EPOCHSECONDS} - -log() { printf '%s\n' "$*" >&2; } - -# Strip anything that could break the hand-rolled JSON below. Package names -# are [A-Za-z0-9.] on CRAN and versions [0-9.-]; anything beyond that in -# these fields is already a driver bug, so dropping characters beats trying -# to quote them. -json_safe() { printf '%s' "${1//[^A-Za-z0-9._-]/}"; } - -json_num() { - if [[ ${1:-} =~ ^[0-9]+([.][0-9]+)?$ ]]; then printf '%s' "$1"; else printf '0'; fi -} - -json_num_or_null() { - if [[ ${1:-} =~ ^[0-9]+([.][0-9]+)?$ ]]; then printf '%s' "$1"; else printf 'null'; fi -} - -# Appends under the same lock compare.R's write_manifest_line takes, so the -# two writers interleave whole lines, never bytes. -append_manifest_line() { - { - flock -x 8 - printf '%s\n' "$1" >> "${manifest}" - } 8>> "${manifest_lock}" -} - -# The last-resort manifest line, for when not even `compare-one.R --error` -# could run: a hardcoded template carrying every schema field, so the -# collector sees a well-formed `error` entry instead of a claimed package -# silently vanishing. The interpolated messages are fixed strings from this -# script, and the name/version fields are sanitised above, so nothing here -# can break the JSON. -write_fallback_line() { - local name=$1 tarball=$2 weight=$3 t_old=$4 t_new=$5 message=$6 - local base=${tarball##*/} version='' - if [[ ${base} == "${name}_"*.tar.gz ]]; then - version=${base#"${name}_"} - version=${version%.tar.gz} - fi - # The plan may have offered a second opinion for this package; this - # last-resort writer cannot know, and false is the harmless default. - local planned=false - local shard=${SHARD:-0} - if ! [[ ${shard} =~ ^[0-9]+$ ]]; then shard=0; fi - local line - printf -v line '{"package":"%s","version":"%s","level":0,"shard":%s,"weight_minutes":%s,"t_total":0,"dep_fingerprint":null,"baseline_planned":%s,"baseline_agrees":null,"result":"error","status":"","status_old":"","status_new":"","new_issues":0,"t_old":%s,"t_new":%s,"old_checked_at":null,"message":"%s","our_cran_version":"%s","our_dev_version":"%s"}' \ - "$(json_safe "${name}")" \ - "$(json_safe "${version}")" \ - "${shard}" \ - "$(json_num "${weight}")" \ - "${planned}" \ - "$(json_num_or_null "${t_old}")" \ - "$(json_num_or_null "${t_new}")" \ - "${message}" \ - "$(json_safe "${REVDEPX_OUR_CRAN_VERSION:-}")" \ - "$(json_safe "${REVDEPX_OUR_DEV_VERSION:-}")" - append_manifest_line "${line}" - printf '.' >> "${fallback_count}" -} - -# One claim: take the next line from this worker's end, under the lock. Sets -# CLAIM_STATUS to claimed, defer or empty, plus CLAIM_NO/CLAIM_LINE/CLAIM_SEQ -# when there is a candidate. The defer decision happens inside the lock -# because it depends on which candidate the cursors point at; the -# first-claim exemption is a flag in the same state file for the same reason. -claim() { - local worker_id=$1 lane=$2 - CLAIM_STATUS=empty - CLAIM_NO=0 - CLAIM_LINE='' - CLAIM_SEQ=0 - { - flock -x 9 - local h t claimed - read -r h t claimed < "${state_file}" - if (( h <= t )); then - local n - if [ "${lane}" = heavy ]; then n=${h}; else n=${t}; fi - local line=${queue_lines[n - 1]} - local name weight - IFS=$'\t' read -r name _ _ weight _ <<< "${line}" - # weight_minutes * 60 * 1.3 = weight * 78: the plan's estimate with the - # shard driver's trailing margin, in whole minutes rounded up (bash has - # no floats) and floored at one, matching shard.R's out_of_time(). - local wi=${weight%%.*} - if [[ ${weight} == *.* ]] && [ -n "${wi}" ]; then wi=$((wi + 1)); fi - if ! [[ ${wi} =~ ^[0-9]+$ ]]; then wi=1; fi - if (( wi < 1 )); then wi=1; fi - if [ "${claimed}" = 1 ] && (( EPOCHSECONDS + wi * 78 > deadline )); then - CLAIM_STATUS=defer - CLAIM_NO=${n} - CLAIM_LINE=${line} - else - if [ "${lane}" = heavy ]; then h=$((h + 1)); else t=$((t - 1)); fi - printf '%d %d 1\n' "${h}" "${t}" > "${state_file}" - printf '%s\t%d\t%s\t%d\t%s\n' \ - "${EPOCHSECONDS}" "${worker_id}" "${lane}" "${n}" "${name}" \ - >> "${claimed_log}" - CLAIM_STATUS=claimed - CLAIM_NO=${n} - CLAIM_LINE=${line} - CLAIM_SEQ=$(((h - 1) + (total - t))) - fi - fi - } 9>> "${state_lock}" -} - -# One claimed package, start to manifest line. Returns nonzero only when the -# driver itself broke mid-package; the caller then writes the fallback line. -process_claim() { - local worker_id=$1 lane=$2 line_no=$3 line=$4 - local name tarball timeout_sec weight rest - IFS=$'\t' read -r name tarball timeout_sec weight rest <<< "${line}" - if [ -z "${name}" ]; then - log "[queue w${worker_id} ${lane}] line ${line_no} is blank; skipping" - return 0 - fi - if ! [[ ${timeout_sec} =~ ^[0-9]+$ ]]; then - log "[queue w${worker_id} ${lane}] ${name}: timeout '${timeout_sec}' is not seconds; using 1200" - timeout_sec=1200 - fi - - local pkg_work=${workdir}/${name} - rm -rf "${pkg_work}" - mkdir -p "${pkg_work}" - - # The version actually being checked, from the tarball's own name; CRAN can - # move on between planning and checking. - local base=${tarball##*/} version='' - if [[ ${base} == "${name}_"*.tar.gz ]]; then - version=${base#"${name}_"} - version=${version%.tar.gz} - fi - - # The halves, one after the other: this engine's whole point. Timed here, - # around each call, because the halves are separate processes now and their - # true per-half seconds are what the manifest and the next plan's cost - # model get. check-half.sh always exits 0 by contract; a nonzero exit means - # the harness around the container broke, and compare-one.R reading the - # half's absent status file turns that into this one package's error line. - local t_old='' t_new='' started status - started=${EPOCHSECONDS} - status=0 - "${CHECK_HALF}" old "${tarball}" "${pkg_work}" "${old_lib}" "${timeout_sec}" 1>&2 || - status=$? - t_old=$((EPOCHSECONDS - started)) - if (( status != 0 )); then - log "[queue w${worker_id} ${lane}] ${name}: check-half.sh old exited ${status} (its contract says 0); reading what is there" - fi - started=${EPOCHSECONDS} - status=0 - "${CHECK_HALF}" new "${tarball}" "${pkg_work}" "${new_lib}" "${timeout_sec}" 1>&2 || - status=$? - t_new=$((EPOCHSECONDS - started)) - if (( status != 0 )); then - log "[queue w${worker_id} ${lane}] ${name}: check-half.sh new exited ${status} (its contract says 0); reading what is there" - fi - - local args=( - --name "${name}" - --version "${version}" - --workdir "${pkg_work}" - --manifest "${manifest}" - --pkgs-dir "${pkgs_dir}" - --baseline-dir "${BASELINE_DIR:-}" - --plan "${PLAN:-plan.json}" - --shard "${SHARD:-0}" - --timeout "${timeout_sec}" - --t-old "${t_old}" - --t-new "${t_new}" - --cran-version "${REVDEPX_OUR_CRAN_VERSION:-}" - --dev-version "${REVDEPX_OUR_DEV_VERSION:-}" - ) - local verdict=ok rc=0 - Rscript "${COMPARE_ONE}" "${args[@]}" 1>&2 || rc=$? - if (( rc != 0 )); then - # compare-one.R died mid-comparison. Run it again in --error mode, which - # skips the parsing and comparing and only writes the line; if even that - # fails, the printf template cannot. - local msg="driver error: compare-one.R failed (exit ${rc})" - verdict=error-line - log "[queue w${worker_id} ${lane}] ${name}: ${msg}; writing an error line" - local rc2=0 - Rscript "${COMPARE_ONE}" "${args[@]}" --error "${msg}" 1>&2 || rc2=$? - if (( rc2 != 0 )); then - verdict=fallback - log "[queue w${worker_id} ${lane}] ${name}: compare-one.R --error failed too (exit ${rc2}); writing the fallback line" - write_fallback_line "${name}" "${tarball}" "${weight}" \ - "${t_old}" "${t_new}" "${msg}" - fi - fi - - # The running count, load-test.sh style: single-byte appends to an O_APPEND - # descriptor do not interleave, so the number is exact without a lock. It - # is a progress indicator, not data. - printf '.' >> "${done_count}" - local finished parts total_s - finished=$(wc -c < "${done_count}") - total_s=$((t_old + t_new)) - parts="old ${t_old}s + new ${t_new}s" - printf '[queue w%d %-5s %*d/%d] %s %s in %ds (%s)\n' \ - "${worker_id}" "${lane}" "${width}" "${finished}" "${total}" \ - "${name}" "${verdict}" "${total_s}" "${parts}" >&2 - return 0 -} - -worker() { - local worker_id=$1 lane=light claims=0 - if (( worker_id == 1 )); then lane=heavy; fi - while :; do - claim "${worker_id}" "${lane}" - case ${CLAIM_STATUS} in - empty) - log "[queue w${worker_id} ${lane}] queue empty after ${claims} claim(s)" - return 0 - ;; - defer) - local dname dweight - IFS=$'\t' read -r dname _ _ dweight _ <<< "${CLAIM_LINE}" - log "[queue w${worker_id} ${lane}] stopping: ${dname} (line ${CLAIM_NO}, ~${dweight} min) will not finish before the deadline; leaving it for the deferred tail" - return 0 - ;; - claimed) - claims=$((claims + 1)) - local cname - IFS=$'\t' read -r cname _ <<< "${CLAIM_LINE}" - log "[queue w${worker_id} ${lane}] claim ${CLAIM_SEQ}/${total}: ${cname} (line ${CLAIM_NO})" - # The subshell is this worker's per-package safety net: `set -u` - # kills a shell outright and a function cannot catch that for its - # caller, but a process boundary can. Whatever ends the package's - # flow in there, the worker writes the fallback line and moves on to - # the next claim rather than dying with work left in the queue. - local rc=0 - (process_claim "${worker_id}" "${lane}" "${CLAIM_NO}" "${CLAIM_LINE}") || - rc=$? - if (( rc != 0 )); then - local fname ftarball fweight - IFS=$'\t' read -r fname ftarball _ fweight _ <<< "${CLAIM_LINE}" - log "[queue w${worker_id} ${lane}] ${fname}: worker error (exit ${rc}); writing the fallback line" - write_fallback_line "${fname}" "${ftarball}" "${fweight}" \ - '' '' "driver error: worker failed unexpectedly (exit ${rc})" - printf '.' >> "${done_count}" - fi - ;; - esac - done -} - -log "[queue] ${total} package(s), ${workers} worker(s) (w1 heavy, $((workers - 1)) light), ${REVDEPX_MEMORY:-no} memory cap per check, deadline in $(((deadline - EPOCHSECONDS) / 60)) min" - -pids=() -for ((i = 1; i <= workers; i++)); do - worker "${i}" & - pids+=($!) -done - -# Wait for every worker by pid: a worker that dies entirely -- OOM-killed, a -# bug -- must be seen, not merely absent. -worker_failures=0 -for ((i = 0; i < ${#pids[@]}; i++)); do - rc=0 - wait "${pids[i]}" || rc=$? - if (( rc != 0 )); then - worker_failures=$((worker_failures + 1)) - log "[queue] WORKER $((i + 1)) DIED (exit ${rc}); anything it claimed but did not report gets a fallback line in the sweep" - fi -done - -# The sweep: by now every claim must have a manifest line. A worker killed -# between its claim and its write is the one path the per-package ladders -# above cannot cover, so it is covered here, from the records: claimed.log -# knows what was taken, the manifest knows what was reported. jsonlite and -# the printf template both put "package" first on the line, so the name comes -# out without a JSON parser. -manifest_names=$(sed -n 's/^{"package":"\([^"]*\)".*/\1/p' "${manifest}" | sort -u) -while IFS=$'\t' read -r _ _ _ line_no cname; do - if [ -z "${cname}" ]; then continue; fi - if ! grep -qxF -- "${cname}" <<< "${manifest_names}"; then - cline=${queue_lines[line_no - 1]} - IFS=$'\t' read -r sname starball _ sweight _ <<< "${cline}" - log "[queue] ${cname}: claimed (line ${line_no}) but never reported; writing the fallback line" - write_fallback_line "${sname}" "${starball}" "${sweight}" '' '' \ - 'driver error: worker exited before reporting this package' - fi -done < "${claimed_log}" - -claims=$(grep -c . "${claimed_log}" || true) -completed=$(wc -c < "${done_count}") -fallback_lines=$(wc -c < "${fallback_count}") -deferred=$((total - claims)) -finished_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ') - -# The shard driver's accounting: what was taken, what was finished, what was -# never claimed (those get `deferred` manifest lines from shard.R, not from -# here -- the queue only ever writes lines for packages it claimed). -printf '{"workers":%d,"claims":%d,"completed":%d,"fallback_lines":%d,"deferred_at_exit":%d,"started_at":"%s","finished_at":"%s"}\n' \ - "${workers}" "${claims}" "${completed}" "${fallback_lines}" "${deferred}" \ - "${started_at}" "${finished_at}" > "${workdir}/queue-state.json" - -log "[queue] done: ${claims} claimed, ${completed} completed, ${deferred} deferred, ${fallback_lines} fallback line(s), ${worker_failures} worker death(s), $((EPOCHSECONDS - started_epoch))s" - -# What the compiler cache came to. `du` rather than `ccache --show-stats`, -# because ccache lives in the check image and not on the runner; the number -# that actually answers "did this help" is the per-half durations the run -# already records, and this line only says the cache was there and filled. -if [ -n "${REVDEPX_CCACHE_DIR:-}" ] && [ -d "${REVDEPX_CCACHE_DIR}" ]; then - log "[queue] ccache: $(du -sh "${REVDEPX_CCACHE_DIR}" 2> /dev/null | cut -f1) in ${REVDEPX_CCACHE_DIR}" -fi - -exit 0 diff --git a/.github/workflows/revdepx/README.md b/.github/workflows/revdepx/README.md deleted file mode 100644 index f8fe727..0000000 --- a/.github/workflows/revdepx/README.md +++ /dev/null @@ -1,285 +0,0 @@ -# revdepx: the core of the revdep4 workflow - -This directory is the core of the reverse-dependency-check workflow -`revdep4.yaml` (the *queue* engine): -a package's CRAN half and dev half run **sequentially**, -each in its own Docker container, -and a bash work queue checks several packages at once. - -It exists because of a diagnosis: -revdep2 ran the two halves as two simultaneous `R CMD check` processes -on one host, -and simultaneously checking the same package against two libraries -is not a supported mode of operation for the packages being checked. -The PSOCK port collision that needed the `R_PARALLEL_PORT` split -was one member of an open-ended class — -shared TMPDIR, shared caches, shared locks, -any singleton a check believes it owns. -revdep4 dissolves the class by never being simultaneous, -and by containers. -(A sibling *pair* engine, revdep3, -dissolved it by isolation alone — -both halves concurrently, one container each; -it was validated live and retired with its unmerged PR, -and its runs remain valid history.) -Everything that was *not* about that flaw — -the planner, the cost model, the baseline lineage, -the manifest and report machinery, -the hard-won robustness patterns — -lives here, shared. - -The history of most design decisions in these scripts -is documented in `../revdep2/README.md`; -comments citing concrete run ids refer to revdep2 runs. -This file documents what changed and why, -and the contract that keeps the two workflows interchangeable. - -## Why the two workflows can pull each other's results - -Every reuse channel is keyed so that -"a run of the other workflow" is indistinguishable -from "an earlier run of this one": - -- **Artifacts** share one family: - `revdepx-plan`, `revdepx-pkg`, `revdepx-baseline`, - `revdepx-timings`, `revdepx-report`, - `revdepx-results--`, - `revdepx-universe-report`. - `plan.R` walks the completed runs of the files in `REVDEPX_WORKFLOWS`, - youngest first across the union. -- **Baselines** (old-version check results) are valid across runs - because every run checks inside the *same* container platform: - a baseline row records the revdep's version, - our CRAN version, the container R series, - the base-image tag, the dependency fingerprint, - and the date of the actual old check. - `plan.R` offers a row as a second opinion - only when all of them still match; - the old half runs fresh regardless, - and `baseline_agrees` records whether it reproduced the stored result. - The base-image condition is also the firewall - against revdep2-era baselines, - which were measured on the runner's own toolchain - and carry no tag. -- **Timings** record one canonical per-package number: - `seconds` = the mean of the per-half durations that exist — - what one half costs, which the plan doubles into a package's bill. - (Retired pair-engine rows carry the pair's shared wall clock - in both fields; the unit still holds.) - Shard-level rows (setup, install minutes) are engine-tagged, - so `calibration()` takes them only from queue runs; - the per-package pool is shared. -- **Reports and `retry-run`**: `manifest.json` and the report files - have one schema and one result vocabulary, - so `retry-run: ` accepts any earlier revdepx run - and carries its good results into the new report. -- **The universe image** on GHCR is one lineage (`revdepx-universe`), - updated by whichever workflow ran last - and consumed by whichever runs next. -- **Serialization**: both workflows share one concurrency group - per checked ref (`revdepx-`), - because they also share the committed `revdep/` report - and the baseline lineage; - interleaving them would race both. - -## The image lifecycle - -- **Base image** (`revdepx-base:r--`): - rocker/r-ver at the resolved R version (input `r-version`, - default `oldrel` — a deliberately still target) - plus the check toolchain (qpdf, ghostscript, pandoc, TeX, pak). - The tag hashes `base-image.sh` itself, - so the image is immutable until the recipe or the R version changes, - and `plan.R` can *name* the tag without docker — - which is how baseline rows are keyed to the platform. -- **Universe image** (`revdepx-universe:latest` and `:run-`): - the base plus the whole dependency universe - and every system requirement, - built by `image.R` *inside* a container and committed. - Dependencies resolve against CRAN *and* Bioconductor - (`dep_db()` in `util.R`): - the checked packages are CRAN reverse dependencies, - but what they depend on may live in either repository — - run 32158907637 reported 121 packages `depmissing` - because the planner intersected dependency lists - with CRAN metadata alone. - A fresh-enough `:latest` standing on the same base tag - is used as the starting layer, - so a quiet CRAN week costs a delta install, not a rebuild; - past `REVDEPX_IMAGE_MAX_AGE_DAYS` (default 14) - the build starts from the base again, - so accreted layers and stale system packages age out. - igraph itself is evicted from the image's library: - the two halves mount their own single-package libraries - (CRAN release and dev binary) in front of it. -- **Fallbacks**, in order: - a universe job that cannot *push* ships the image - as a `revdepx-universe-image` artifact and shards `docker load` it; - a universe job that failed entirely leaves the shards - to build a shard-local image from the base - (`shard-prep.sh`), using the shard's own install union — - revdep2's per-shard install, demoted to disaster recovery. - -## What checking looks like - -One half = one container (`check-half.sh`): -the tarball, the half's library and the work directory bind-mounted, -`R_LIBS=:`, -`timeout` inside the container, -the same `_R_CHECK_*` environment the workflow forwards, -a per-container `/tmp` on the big disk, -a memory cap so a hungry check kills its container -and not the runner, -and the container's exit status preserved -(124 = timeout, with an OOM marker when the kernel killed it). -The `.Rcheck` directory, the stamped `driver.log` -and the `status` file land in the same relative layout -revdep2 produced, -so parsing, comparison, salvage and reporting -carry over unchanged (`compare.R`). -The driver log doubles as the per-stage timing record -(`_R_CHECK_TIMINGS_` stays off -to keep the compared check logs free of timing noise): -the salvage copies it into the results artifact -for every half that failed — -including a killed half's partial `.Rcheck`, -whose check log lists every stage it completed — -and `check-half.sh` prints a failed half's stage timeline -into the job log. - -The queue runs two such containers back to back per package, -several packages at once (`../revdep4/queue.sh`). -Both halves are always fresh checks. - -## Dropped from revdep2, deliberately - -- The `R_PARALLEL_PORT` split and every per-mechanism - interference patch — the isolation is categorical now. -- The preflight job, the `revdep2-lib`/`revdep2-lib-index` artifacts, - the donor-run walk and the tar pack/unpack machinery — - the universe image is the library artifact, - and a registry pull is the restore. -- Per-shard host installs of the dependency union - (kept only as the fallback above). -- Host TinyTeX, pandoc, qpdf and apt setup on every shard — - in the image, once. -- The preflight-union arithmetic - (which packages ≥ 2 shards need): - with one shared image every package is installed exactly once - whatever the shard layout. - -## Knobs - -Workflow inputs are documented in the two yamls. -Repository variables (`vars.*`) shared by both: -`REVDEPX_SHARD_BUDGET_MINUTES`, `REVDEPX_MAX_PARALLEL`, -`REVDEPX_SHARD_CAPACITY_MINUTES`, `REVDEPX_BASELINE_MAX_AGE_DAYS`, -`REVDEPX_IMAGE_MAX_AGE_DAYS`, `REVDEPX_TIMEOUT_FACTOR`, -`REVDEPX_TIMEOUT_MIN_MINUTES` -(the per-check timeout floor, default 30 min: -a saturated shard runs each check at roughly half speed, -so a floor sized for uncontended times kills healthy checks), -`REVDEPX_DEADLINE_MINUTES`, -`REVDEPX_COMMIT_REPORT`, -`REVDEPX_MEMORY_PER_CHECK` (per-check container memory cap, default 6g; -a machine-sized cap is derived when it is cleared), -`REVDEPX_CHECK_FLAGS` (compiler flags appended for the check's own compile -of the package under test, default `-g0` — template-heavy Stan/TMB -translation units spend most of their compiler memory on debug info; -set `-g` to restore CRAN's own flags), -`REVDEPX_CHECK_MAKEFLAGS` (MAKEFLAGS inside the check container, -default `-j1`: the memory cap is sized for one compiler process). -Check containers also run with `_R_CHECK_LIMIT_CORES_=TRUE` -(overridable through the environment), -the value CRAN's own machines use: -a test suite sizing itself from `parallel::detectCores()` -would otherwise fan out one worker per runner core, -and four such checks side by side -drove 4-core shards to load 5–13 — -the direct cause of the floor timeouts in run 32574134229; -queue engine only: `REVDEPX_WORKERS`. -Script-level environment variables are documented -in the header of each script. - -## Backlog - -Measured ideas, not yet implemented; numbers from the `most`/depth-2 pair -(runs 32158907637 and 32196879628, 3435 packages, 40 shards, 20 lanes). - -- **Universe membership threshold.** - The universe image bakes the whole install union - (4675 packages, ~14.4 GB container delta over the ~1.9 GB base), - but membership is extremely long-tailed: - 1696 of the 4675 are needed by exactly one shard, - and only 309 by all 40. - Limiting the image to packages needed by ≥ a fraction of shards - and installing the rest per shard on arrival would give, - at ≥ 1/4 of shards: an image of ~1151 packages (24%), - with a per-shard delta install of ~218 packages (max 303); - at ≥ 1/8: ~1725 packages baked, ~125 per shard. - What it buys: a much shorter universe job - (the critical path every shard waits on), - ~10 GB less registry churn per build - (the committed layer re-uploads whole every time), - and smaller pulls. - What it costs: ~5–15 min of per-shard delta install (parallel, off the - critical path), duplicated installs for packages under the threshold - (~2.5× for the tail at 1/4, roughly +2 runner-hours per full run), - and the delta must be committed shard-locally because apt-level - sysreqs of tail packages cannot ride a bind mount — - the rung-3 fallback machinery in `shard-prep.sh` already does exactly - this from the base image and would start from the pulled universe - instead. -- **Shard-count layers.** - Simulation over the measured shard durations - (mean 2.57 h, cv 0.11 at ~86 packages/shard; - noise decomposed into a systemic runner-speed part - and a package-mix part that averages out by the CLT) - says full waves win: - at two layers, 40 shards beat 38 (+8 min) and 35 (+28 min) in - expected makespan; at three layers, 60 beats 55 (+13 min) and - 50 (+43 min), with 57–60 within noise of each other. - Slack below a full wave only pays when per-shard variance is far - larger than measured — the planner's `max(heaviest, sum/workers)` - bound already guards the giant-package case — so the planner keeps - its existing rule (beyond one wave, whole waves: - `lanes × ceiling(by_capacity / lanes)`), and the one genuinely bad - region, a small overflow layer (41 shards ≈ +1.7 h over 40), - is exactly what that rule already avoids. - The live pathology that motivated the question — - the last shard of run 32196879628 waiting 1.4 h for a runner — - was org-pool contention from unrelated workflows, - which no shard arithmetic removes. -- **Further compile-memory switches**, if `-g0` + `-j1` + 6g still - leave OOM-killed compilers: GCC garbage-collector tuning - (`--param ggc-min-expand=10 --param ggc-min-heapsize=32768`) trades - compile time for peak memory; `-Wl,--no-keep-memory` does the same - for the final link; `-O1` would cut further but changes generated - code enough to distort check timings. Rust builds (`caugi`, - `zoomerjoin`, `RPesto`) ignore all of these — cargo's memory story - is its own. -- **Report the memory verdicts**: count OOM markers and - compiler-kill detections per run in the README summary, so a - cap regression is visible without opening `failures.md`. - -## Operational notes - -- First run on a repository: the GHCR packages - (`revdepx-base`, `revdepx-universe`) are created on first push - and must be allowed for `GITHUB_TOKEN` writes - (they are, by default, for images pushed from the owning repo). - If the organization forbids it, - every run still works through the artifact fallback, - at the price of re-building the universe each run. -- Image housekeeping: `:run-` tags accumulate one per run; - an org-level GHCR retention policy (or an occasional manual sweep) - keeps the package list tidy. - Nothing consumes a `:run-` tag after its run's shards finished. -- revdep2-era baselines and timings are never consumed: - baselines fail the base-image condition, - and timings live on `revdep2.yaml` runs the history walk - does not visit. - The first revdepx run therefore checks everything fresh - and calibrates from CRAN times alone — by design, - since its checks run on a different platform (pinned oldrel - containers) than revdep2's ever did. diff --git a/.github/workflows/revdepx/base-image.sh b/.github/workflows/revdepx/base-image.sh deleted file mode 100755 index cc0e1cc..0000000 --- a/.github/workflows/revdepx/base-image.sh +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env bash -# Ensure the revdepx base image exists, and name it. -# -# The base image is rocker/r-ver plus what `R CMD check --as-cran` needs and -# an R distribution deliberately does not carry -- qpdf and ghostscript for -# the PDF checks, pandoc for vignettes, enough of TeX Live to build manuals -# and vignettes, tidy for HTML validation, the tcl/tk runtime libraries -# (tcltk is a *base* R package, so pak's sysreqs machinery never sees it as -# a dependency and never installs libtcl for it: in run 32281237129 a -# universe built for a small package set had no other package pulling tcl -# in, and every tcltk-using package failed to *install* with "libtcl8.6.so: -# cannot open shared object file") -- plus pak, jsonlite and callr, so -# an image build or an in-container install can start without bootstrapping -# any of them. callr (and the processx it brings) is not a convenience: it is -# what puts a clock on the calls that have none of their own -- util.R's -# run_with_timeout() degrades to an *unbounded* inline call without it, and -# image.R's chunked installs, metadata probes and sysreqs surveys all run -# inside this container. pak vendors its own private copies of both and -# exports neither, which is why they are installed here in their own right -# (the same lesson revdep2 learnt on the host). Everything downstream (the -# universe image, every check container) stands on this. -# -# Usage: -# base-image.sh # ensure it exists, print ref -# base-image.sh --tag-only # print the tag; no docker -# -# is a RESOLVED version like 4.5.3, never an alias. The default -# of "oldrel" lives in the yaml, which resolves the alias before calling: -# an alias is a moving target, and everything downstream -- this tag, the -# baseline-validity comparison in plan.R -- needs one fixed string that -# means the same thing in every job of the run. -# -# The tag is r--. -# That buys two properties at once. The image is a fixed target: as long as -# this script is byte-identical, the tag names exactly one recipe, rebuilds -# are no-ops (the manifest already exists), and a run in flight keeps the -# image it resolved. And the tag is computable *without docker and without -# running this script* -- plan.R, running in a parallel job, hashes its own -# checkout's copy of this file with tools::md5sum() and gets the same string -# for its baseline comparison. The price is that ANY edit to this file -- -# the package list, a comment, this sentence -- rolls the tag and forces one -# rebuild. Accepted: a rebuild costs minutes, a tag that fails to roll on a -# recipe change serves stale images for ever, and a hash of anything less -# than the whole file would reintroduce exactly that gap. -# -# Environment: -# REVDEPX_PUSH=1 - push : and :latest-r- after building -# (only jobs with packages:write set this). A push -# failure under REVDEPX_PUSH=1 is a hard error: every -# downstream job pulls this ref from the registry and -# the base image has no artifact fallback, so failing -# here is the one loud failure instead of four -# confusing ones later. -# GITHUB_STEP_SUMMARY - appended to when set. -# -# Everything informational goes to stderr. The LAST line on stdout is the -# full image ref (image:tag) -- that line is what callers capture. - -set -eu - -hash=$(md5sum "$0" | cut -c1-12) - -if [ "${1:-}" = "--tag-only" ]; then - version=${2:?usage: base-image.sh --tag-only } - echo "r-${version}-${hash}" - exit 0 -fi - -version=${1:?usage: base-image.sh } -image=${2:?usage: base-image.sh } -tag="r-${version}-${hash}" -ref="${image}:${tag}" - -summary() { - if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then - echo "$1" >> "${GITHUB_STEP_SUMMARY}" - fi -} - -# `docker manifest inspect` asks the registry without pulling anything; the -# caller has already run `docker login`. A missing manifest and a registry -# error look the same here, and are treated the same: build it ourselves. -if docker manifest inspect "${ref}" > /dev/null 2>&1; then - echo "Base image ${ref} exists in the registry; nothing to build." >&2 - summary "Base image: reused \`${ref}\`." - echo "${ref}" - exit 0 -fi - -echo "Base image ${ref} not found in the registry; building it." >&2 - -# The Dockerfile, piped straight into docker build; no context directory is -# needed because nothing is COPYed. Two traps are handled inside it: -# * rocker's baked CRAN repository is a p3m.dev snapshot frozen on the day -# rocker built the image, so pak and jsonlite are installed from the -# rolling `latest` binary snapshot for the image's own Ubuntu release -# instead -- resolved from /etc/os-release, not hard-coded, so an -# r-version whose rocker base moved to a newer Ubuntu keeps working. -# * the org.opencontainers.image.source label ties the GHCR package to -# this repository, which is what makes GITHUB_TOKEN pushes land in the -# right place with the right visibility controls. -docker build --tag "${ref}" - >&2 <= 1.80; Ubuntu 24.04's apt -# rustc is 1.75, hence rustup). The toolchain lives system-wide under -# /opt/rust, resolved to current stable at build time and frozen in the -# image -- the way CRAN's own check machines track stable. RUSTUP_HOME must -# persist (the rustc/cargo binaries are rustup proxies that read it), but -# CARGO_HOME must NOT: at check time cargo runs as the mounted-HOME user and -# defaults to a writable ~/.cargo, where /opt/rust is root-owned. -ENV RUSTUP_HOME=/opt/rust -RUN curl -fsSL https://sh.rustup.rs \\ - | CARGO_HOME=/opt/rust sh -s -- -y --no-modify-path --profile minimal --default-toolchain stable \\ - && ln -s /opt/rust/bin/cargo /opt/rust/bin/rustc /usr/local/bin/ \\ - && rustc --version -RUN Rscript -e 'lines <- readLines("/etc/os-release"); \\ - codename <- sub("^VERSION_CODENAME=", "", grep("^VERSION_CODENAME=", lines, value = TRUE)[[1]]); \\ - options(repos = c(CRAN = sprintf("https://p3m.dev/cran/__linux__/%s/latest", codename))); \\ - install.packages(c("pak", "jsonlite", "callr")); \\ - stopifnot(requireNamespace("pak"), requireNamespace("jsonlite"), requireNamespace("callr"))' -LABEL org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY} -EOF - -if [ "${REVDEPX_PUSH:-0}" = "1" ]; then - alias_ref="${image}:latest-r-${version}" - if docker tag "${ref}" "${alias_ref}" >&2 \ - && docker push "${ref}" >&2 \ - && docker push "${alias_ref}" >&2; then - echo "Pushed ${ref} and ${alias_ref}." >&2 - summary "Base image: built and pushed \`${ref}\`." - else - # Loudly, unlike the universe image's push failure: the universe has an - # artifact fallback, the base does not -- build, universe and every - # shard's local fallback all pull this ref from the registry. Carrying on - # here would trade one clear failure at its cause for four confusing - # ones far from it, on a run that cannot succeed anyway. - echo "ERROR: pushing ${ref} failed, and everything downstream pulls it from the registry." >&2 - echo "If GITHUB_TOKEN may not write packages here, allow it (or pre-push a base image by hand)." >&2 - summary "Base image: built \`${ref}\` but the push FAILED; the run cannot proceed." - exit 1 - fi -else - echo "REVDEPX_PUSH is not 1; built ${ref} locally without pushing." >&2 - summary "Base image: built \`${ref}\` locally (no push)." -fi - -echo "${ref}" diff --git a/.github/workflows/revdepx/build.R b/.github/workflows/revdepx/build.R deleted file mode 100644 index 173e2d3..0000000 --- a/.github/workflows/revdepx/build.R +++ /dev/null @@ -1,144 +0,0 @@ -# Build the package under test once: a source tarball and a platform binary, -# so no shard pays the compilation twice. Shards install the binary; the -# tarball is kept alongside for reference and local reproduction. -# -# This runs INSIDE the base container, not on the runner: the binary must -# load under the container's R -- the pinned oldrel, not whatever the runner -# image ships -- and an R package binary does not survive a minor-version -# boundary. The build dependencies are therefore this script's own problem -# (REVDEPX_BUILD_DEPS), because the container starts with nothing but R, pak -# and the toolchain. -# -# Deliberately independent of the plan, so the job can run in parallel with -# planning; everything it needs is the checkout. -# -# Environment variables: -# OUT_DIR - where tarball, binary and metadata land (default: pkg) -# REVDEPX_BUILD_DEPS - if truthy, pak-install the package's own hard -# dependencies (with system requirements) first - -source(file.path( - dirname(sub("--file=", "", grep("^--file=", commandArgs(), value = TRUE))), - "util.R" -)) - -out_dir <- env_chr("OUT_DIR", "pkg") -dir.create(out_dir, recursive = TRUE, showWarnings = FALSE) - -if (env_flag("REVDEPX_BUILD_DEPS")) { - # The image's baked repo may be a frozen P3M snapshot (rocker pins the last - # date a tag's R version was current); the build must resolve against CRAN - # now, like everything else in the run. - codename <- tryCatch( - system(". /etc/os-release && echo $VERSION_CODENAME", intern = TRUE), - error = function(e) "" - ) - if (nzchar(codename)) { - options( - repos = c( - CRAN = sprintf("https://p3m.dev/cran/__linux__/%s/latest", codename) - ) - ) - } - inform("Installing build dependencies of the package under test") - result <- pak_install( - "deps::.", - lib = .libPaths()[[1]], - timeout_seconds = install_timeout_seconds() - ) - if (!isTRUE(result$ok)) { - stop( - "Installing build dependencies failed: ", - result$message %||% "see the log above", - call. = FALSE - ) - } -} - -desc <- read.dcf("DESCRIPTION")[1, ] -package <- unname(desc[["Package"]]) -dev_version <- unname(desc[["Version"]]) - -head_sha <- tryCatch( - system2("git", c("rev-parse", "HEAD"), stdout = TRUE, stderr = NULL)[[1]], - error = function(e) "" -) -if (!nzchar(head_sha)) { - head_sha <- env_chr("GITHUB_SHA") -} - -inform("Building ", package, " ", dev_version) -# `--no-build-vignettes` on top of revdep2's `--no-manual`: this binary -# exists to be installed into the checks' new-half library, and no check -# ever builds or reads the package-under-test's own vignettes -- the revdeps' -# vignettes are what get built, inside their own checks. Building them here -# would drag the whole Suggests tree (knitr, rmarkdown and friends) into a -# container that needs none of it; the first live run failed on exactly -# that ("vignette builder 'knitr' not found"). -status <- system2( - "R", - c("CMD", "build", "--no-manual", "--no-build-vignettes", ".") -) -if (status != 0) { - stop("R CMD build failed", call. = FALSE) -} -tarball <- sort( - list.files(pattern = sprintf("^%s_.*[.]tar[.]gz$", package)), - decreasing = TRUE -)[[1]] - -inform("Building the binary from ", tarball) -binary_dir <- file.path(out_dir, "bin") -dir.create(binary_dir, recursive = TRUE, showWarnings = FALSE) -build_lib <- tempfile("lib-") -dir.create(build_lib) -status <- system2( - "R", - # Quoted: system2() quotes the command, but not the arguments. - c("CMD", "INSTALL", "--build", "-l", shQuote(build_lib), shQuote(tarball)) -) -if (status != 0) { - stop("R CMD INSTALL --build failed", call. = FALSE) -} -binary <- sort( - list.files(pattern = sprintf("^%s_.*_R_.*[.]tar[.]gz$", package)), - decreasing = TRUE -)[[1]] -# Copy, not file.rename(): the working directory and OUT_DIR are two -# different bind mounts here, rename(2) across mounts fails with EXDEV, -# and file.rename() reports that as a return value nobody is forced to -# read. Run 32068779192 shipped a two-file artifact -- meta.json naming a -# binary that was never moved -- and every shard failed installing it. -# file.copy() works across mounts and its result is checked. -if (!isTRUE(file.copy(binary, file.path(binary_dir, binary)))) { - stop("Copying ", binary, " into ", binary_dir, " failed", call. = FALSE) -} -unlink(binary) -if (!isTRUE(file.copy(tarball, file.path(out_dir, tarball)))) { - stop("Copying ", tarball, " into ", out_dir, " failed", call. = FALSE) -} - -write_json( - list( - package = package, - dev_version = dev_version, - sha = head_sha, - r_version = paste( - R.version$major, - sub("[.].*$", "", R.version$minor), - sep = "." - ), - platform = R.version$platform, - tarball = tarball, - binary = file.path("bin", binary), - built_at = now_utc() - ), - file.path(out_dir, "meta.json") -) -inform("Binary: ", binary) - -append_summary(c( - "## revdepx build", - "", - sprintf("Built `%s` %s: `%s`.", package, dev_version, binary) -)) diff --git a/.github/workflows/revdepx/check-half.sh b/.github/workflows/revdepx/check-half.sh deleted file mode 100755 index e746694..0000000 --- a/.github/workflows/revdepx/check-half.sh +++ /dev/null @@ -1,309 +0,0 @@ -#!/usr/bin/env bash -# Run one half of a package's check -- old or new -- in a container of its own. -# -# The container replaces a class of fixes this workflow used to make one at a -# time. Two concurrent checks on a shared host collide on anything with a -# global name, and the collision that actually drew blood was the PSOCK port: -# `parallel` picks its default port once per session, from the RNG and the -# clock; `set.seed()` -- which examples, vignettes and tests call constantly, -# for reproducibility -- makes the draw deterministic, and two halves that -# start together draw the same port. Run 31893156685 reported `cia` and -# `TDApplied` newly broken with nothing wrong with either. The fix then was a -# hand-picked R_PARALLEL_PORT per half: correct, and strictly per-mechanism. -# The next global name -- a socket path in /tmp, a lock file, a port some -# test suite hard-codes -- was still waiting its turn. A container gives each -# half its own network, PID and mount namespaces and its own /tmp, so the -# collisions dissolve as a class: the port hack is gone because there is -# nothing shared left to dodge. -# -# Usage: -# check-half.sh -# -# All paths must be absolute -- docker mounts them into the container. -# -# Environment: -# REVDEPX_IMAGE - universe image ref to run the check in (required) -# REVDEPX_MEMORY - per-container memory cap, e.g. 6g (optional). -# --memory-swap is set to the same value, so the cap -# is real: no swapping past it; a check that wants -# more is OOM-killed inside its container instead of -# taking the runner down. -# REVDEPX_CPU_SHARES - relative CPU weight under contention (default 512, -# half a default container's 1024, so checks yield -# to whatever else the runner is doing -- the job -# nice/ionice did in the host design) -# REVDEPX_UNIVERSE_LIB - dependency library inside the image -# (default /opt/revdepx/lib) -# REVDEPX_CHECK_FLAGS - compiler flags appended (via ~/.R/Makevars) to -# C/C++/Fortran flags for the *check's* compile of -# the package under test; default -g0. Debug info is -# what a template-heavy translation unit spends its -# gigabytes on -- run 32158907637's dmesg watch -# caught cc1plus OOM-killed at ~3.3 GiB anon-rss -# compiling Stan/TMB code, and -g0 cuts exactly that -# -- while both halves get identical flags, so the -# comparison stays fair. Set to '-g' to restore -# CRAN's own flags. -# REVDEPX_CHECK_MAKEFLAGS - MAKEFLAGS inside the check container (default -# -j1): one compiler process per check, so the -# memory cap is sized for one cc1plus, not for a -# package Makefile's idea of parallelism. -# _R_CHECK_LIMIT_CORES_ - passed into the container, default TRUE, the -# value CRAN's own check machines use: a cluster or -# fork call asking for more than 2 workers errors -# instead of spawning them. Without it a test suite -# that sizes itself from parallel::detectCores() -# sees the runner's 4 cores and fans out 4 workers -# per check -- with 4 concurrent checks, run -# 32574134229's 4-core shards ran at load 5-13, -# and every floor timeout sat in exactly those -# windows. Identical for both halves, so the -# comparison is unaffected either way. -# -# Leaves // holding the .Rcheck directory, `driver.log` (what -# R CMD check said, each line stamped with elapsed seconds), `status` (the -# check's exit code; 124 is the timeout, per coreutils `timeout`; 125 and up -# is docker failing, not the package) and `oom` when the kernel's OOM killer -# ended it. Always exits 0: which half failed, and how, is for the caller to -# read out of those files. - -set -u - -# The environment a check sees. A container starts clean -- that is its whole -# point -- so what the workflow sets for checks is forwarded by name, from -# this one list. `-e NAME` without a value hands the client's current value -# through, and is emitted only for names that are actually set, so the -# container never receives an empty string where the workflow meant "unset". -forward_env=( - NOT_CRAN - R_REMOTES_NO_ERRORS_FROM_WARNINGS - RGL_USE_NULL - _R_CHECK_CRAN_INCOMING_ - _R_CHECK_SYSTEM_CLOCK_ - _R_CHECK_FUTURE_FILE_TIMESTAMPS_ - _R_CHECK_FORCE_SUGGESTS_ - _R_CHECK_DONTTEST_EXAMPLES_ - _R_CHECK_CRAN_INCOMING_USE_ASPELL_ - _R_CHECK_TIMINGS_ - _R_CHECK_EXAMPLE_TIMING_THRESHOLD_ - _R_CHECK_TESTS_NLINES_ -) - -# REVDEPX_CCACHE_DIR, when set, is a host directory the caller keeps for the -# whole shard: it is mounted as the container's CCACHE_DIR and /usr/lib/ccache -# goes on PATH, so the compilers R CMD check invokes run through ccache. -# -# This is where a compiler cache has something to cache. The two halves of a -# revdep compile the SAME sources minutes apart -- only the package under test -# in `lib-half` differs -- and inside the container both halves see identical -# paths, because each mounts its own workdir at the same /revdepx/out. The -# second half therefore hits on everything that does not depend on the package -# under test. A revdep that names it in `LinkingTo` reads headers that really -# do differ between halves, and ccache misses on exactly those objects, -# because what it hashes is the preprocessed source: the comparison the two -# halves exist to make is not weakened by caching, it just stops paying twice -# for the parts that were never different. -half=$1 -tarball=$2 -work=$3 -lib_half=$4 -seconds=$5 - -out="${work}/${half}" -# The container's /tmp and HOME are subdirectories of the half's own workdir: -# per half, on the big disk, gone with the workdir. A check that fills its -# /tmp fills its own, not its sibling's and not the runner's. -mkdir -p "${out}/tmp" "${out}/home" -# Real /tmp semantics inside the container: some test suites assume the -# sticky world-writable mode even when everything runs as one user. -chmod 1777 "${out}/tmp" 2> /dev/null || true - -# The check's own compile -- R CMD check installs the package under test from -# source -- runs with these flags appended through the container HOME's -# ~/.R/Makevars, which GNU make reads *after* R's Makeconf, so `+=` extends -# what the image's R was built with. The default, -g0, drops debug info: that -# is where a template-heavy translation unit (Stan, TMB) spends most of its -# compiler memory, and the last -g* flag on the line wins, so appending -# neutralises Makeconf's -g without touching optimisation. Identical for both -# halves by construction -- the file is written per half, from the same -# environment. -check_flags=${REVDEPX_CHECK_FLAGS:-"-g0"} -if [ -n "${check_flags}" ]; then - mkdir -p "${out}/home/.R" - { - echo "# Written by check-half.sh; identical for both halves." - for flag_var in CFLAGS CXXFLAGS CXX11FLAGS CXX14FLAGS CXX17FLAGS \ - CXX20FLAGS CXX23FLAGS FFLAGS FCFLAGS; do - echo "${flag_var} += ${check_flags}" - done - } > "${out}/home/.R/Makevars" -fi - -# Seconds since the check started, in front of every line it prints. -# -# `R CMD check` only reports a stage's own time when it exceeds its -# threshold, and never for the stage it was killed in -- which is the one -# worth knowing about. Stamping the stream costs nothing and turns "timed out -# at * checking examples with --run-donttest" into how long every stage -# before it took, and how long that one had been running. `EPOCHSECONDS` is a -# bash builtin, so this spawns nothing per line. -stamp() { - local start=${EPOCHSECONDS} line - while IFS= read -r line; do - printf '[%5ds] %s\n' "$((EPOCHSECONDS - start))" "${line}" - done -} - -# No --rm: the OOM verdict below is read from the exited container's state, -# which --rm would have deleted before it could be asked. The trap is the -# cleanup instead, and it also covers the outer-timeout path, where the -# docker client is killed while the container is still running. -cidfile="${out}/cid" -rm -f "${cidfile}" -cleanup() { - if [ -s "${cidfile}" ]; then - docker rm -f "$(cat "${cidfile}")" > /dev/null 2>&1 || true - fi - rm -f "${cidfile}" -} -trap cleanup EXIT - -image=${REVDEPX_IMAGE:-} -if [ -z "${image}" ]; then - echo "REVDEPX_IMAGE is not set; there is nothing to run the check in" \ - > "${out}/driver.log" - echo 125 > "${out}/status" - exit 0 -fi - -src_name=$(basename "${tarball}") -universe_lib=${REVDEPX_UNIVERSE_LIB:-/opt/revdepx/lib} - -run_args=( - # A real PID 1 in front of the command: zombies are reaped, signals are - # forwarded, and whatever a test suite leaves running dies with the - # container when the command exits, instead of outliving the check. - --init - --cidfile "${cidfile}" - # Its own loopback -- which *is* the port fix -- plus outbound network for - # the checks that insist on trying. - --network bridge - # A runaway test suite cannot fork the runner to death from in here. - --pids-limit 2048 - --cpu-shares "${REVDEPX_CPU_SHARES:-512}" - # The runner's own uid/gid, so everything written into the mounted workdir - # is the driver's to read and delete. That user has no passwd entry inside - # the container, hence HOME, USER and LOGNAME by hand -- R and half the - # packages it checks ask for them. - --user "$(id -u):$(id -g)" - -e HOME=/revdepx/out/home - -e USER=revdepx - -e LOGNAME=revdepx - # One compiler process per check: the memory cap is sized for one cc1plus, - # and a package Makefile that would fan out -j$(nproc) compilers under a - # 6g cap trades one OOM-killed compiler for several. - -e MAKEFLAGS="${REVDEPX_CHECK_MAKEFLAGS:--j1}" - # CRAN parity, and the shard's own tranquillity: see the header note. - -e _R_CHECK_LIMIT_CORES_="${_R_CHECK_LIMIT_CORES_:-TRUE}" - -v "${tarball}:/revdepx/src/${src_name}:ro" - -v "${out}:/revdepx/out" - -v "${out}/tmp:/tmp" - -v "${lib_half}:/revdepx/lib-half:ro" -) -# CCACHE_MAXSIZE bounds the shard's own disk, not the Actions cache: this -# directory lives and dies with the runner and is never uploaded. -ccache_env="" -if [ -n "${REVDEPX_CCACHE_DIR:-}" ]; then - mkdir -p "${REVDEPX_CCACHE_DIR}" - run_args+=( - -v "${REVDEPX_CCACHE_DIR}:/revdepx/ccache" - -e CCACHE_DIR=/revdepx/ccache - -e "CCACHE_MAXSIZE=${REVDEPX_CCACHE_MAXSIZE:-5G}" - ) - # Single-quoted: `$PATH` is the container's, resolved by the shell in there, - # not this one's spliced in from the runner. - ccache_env='PATH=/usr/lib/ccache:$PATH ' -fi -if [ -n "${REVDEPX_MEMORY:-}" ]; then - run_args+=(--memory "${REVDEPX_MEMORY}" --memory-swap "${REVDEPX_MEMORY}") -fi -for name in "${forward_env[@]}"; do - if [ -n "${!name+x}" ]; then - run_args+=(-e "${name}") - fi -done - -# The library stacks cascade exactly as before: the half-specific library in -# front holds one package -- the CRAN release or the dev build -- and the -# shared trunk behind it is baked into the image. -# -# Xvfb first, where the image carries it: Tk-based packages need a display -# for their examples and tests (and CRAN's own machines check under X); -# `-ac` is safe inside the container's own network namespace, and the -# server dies with the container. DISPLAY is set either way -- pointing at -# a display that is not there fails exactly like no display did. -in_container="command -v Xvfb > /dev/null 2>&1 \ -&& { Xvfb :99 -screen 0 1280x1024x24 -ac -nolisten tcp > /dev/null 2>&1 & } ; \ -DISPLAY=:99 R_LIBS='/revdepx/lib-half:${universe_lib}' TMPDIR=/tmp \ -${ccache_env}timeout --kill-after=60s ${seconds}s \ -R CMD check --no-manual --as-cran --output=/revdepx/out \ -'/revdepx/src/${src_name}'" - -# Two clocks. The inner `timeout` is the check's real deadline: TERM at -# , KILL a minute later, and its exit 124 comes back through docker -# as the container's own code. The outer one is the safety net for the case -# where the inner one cannot fire -- a wedged container runtime, a daemon -# that stops answering -- with 300 s of slack for container start and -# teardown; when it fires it kills the docker client and the trap removes -# the container. The status is PIPESTATUS[0] because the stamping is -# downstream of it. 125, 126 and 127 out of `docker run` mean docker could -# not run the check at all, and the log says so explicitly rather than -# letting it read as a package failure. -run_started=${EPOCHSECONDS} -timeout "$((seconds + 300))s" \ - docker run "${run_args[@]}" "${image}" sh -c "${in_container}" 2>&1 | - stamp > "${out}/driver.log" -status=${PIPESTATUS[0]} -# Both clocks exit 124, and they mean different things: the inner one is the -# check hitting its budget (a result about the package), the outer one is a -# wedged container runtime (a result about the runner). Tell them apart by -# when the axe fell -- the outer clock cannot fire before seconds+300 -- and -# report the outer case as a docker-level failure so the manifest does not -# call a healthy package's check "timed out". -if [ "${status}" -eq 124 ] \ - && [ "$((EPOCHSECONDS - run_started))" -ge "$((seconds + 295))" ]; then - status=125 - echo "the outer safety-net timeout fired at $((seconds + 300))s: the container runtime stopped answering -- the runner's failure, not the package's" \ - >> "${out}/driver.log" -fi -echo "${status}" > "${out}/status" -if [ "${status}" -ge 125 ]; then - echo "docker run exited ${status}: the container could not run -- the runner's failure, not the package's" \ - >> "${out}/driver.log" -fi - -# The one verdict only the container's post-mortem state can give. The -# memory cap turns a hungry check into an OOM kill *inside* the container; -# the check's exit status then looks like any other crash, and this marker -# is what tells the caller the difference. -if [ -s "${cidfile}" ]; then - if [ "$(docker inspect --format '{{.State.OOMKilled}}' "$(cat "${cidfile}")" \ - 2> /dev/null)" = "true" ]; then - echo "oom" > "${out}/oom" - echo "the kernel OOM killer ended this check (memory cap ${REVDEPX_MEMORY:-none})" \ - >> "${out}/driver.log" - fi -fi - -# A failed half's timing record, into the job log while it is cheap to read: -# every driver.log line carries the elapsed stamp, so its stage lines are a -# complete where-did-the-time-go trace up to the kill, and GitHub adds -# wall-clock timestamps on top. Successful halves stay quiet; the salvage -# keeps every failed half's full driver.log in the results artifact besides. -if [ "${status}" -ne 0 ] && [ -f "${out}/driver.log" ]; then - echo "::group::${half} half of ${src_name%_*} exited ${status}: stage timeline" - grep -E '^\[ *[0-9]+s\] \* ' "${out}/driver.log" | tail -n 60 - echo "::endgroup::" -fi - -exit 0 diff --git a/.github/workflows/revdepx/collect.R b/.github/workflows/revdepx/collect.R deleted file mode 100644 index 0792c2a..0000000 --- a/.github/workflows/revdepx/collect.R +++ /dev/null @@ -1,974 +0,0 @@ -# Fan-in for the revdepx workflow: merge every shard's results into one report, one -# manifest, and one baseline for future runs to reuse. -# -# Reads all revdepx-results-* artifacts (every attempt; on a re-run the later -# attempt wins per package), folds in the untouched results of the run being -# retried so the report is always complete, and writes: -# -# revdep/README.md summary, revdepcheck-style -# revdep/problems.md details for packages with new problems -# revdep/failures.md details for packages that could not be checked -# revdep/cran.md the paragraph for cran-comments.md -# revdep/manifest.json one entry per package, machine-readable -# revdep/pkgs/

/ old.rds, new.rds, kept new-version check output -# -# plus the baseline artifact content (baseline.json, old-rds/

.rds): every -# reusable old-version result of this run, stamped with the metadata the next -# plan compares against -- versions, R series, dependency fingerprint, and the -# date the old check *actually* ran (reuse does not refresh it), -# and timings.json: what the checks and the shards actually cost, which is what -# the next plan calibrates its cost model with instead of guessing. -# -# Environment variables: -# RESULTS_DIR - directory the shard artifacts were downloaded into (required) -# PLAN - plan.json (default: plan.json) -# RETRY_DIR - the revdepx-report artifact of the run being retried, if -# any -- a run of either workflow, since both publish it -# OUT_DIR - report directory (default: revdep) -# BASELINE_OUT - baseline directory (default: baseline) -# TIMINGS_OUT - timings directory (default: timings) -# -# Reads GH_TOKEN, if it has one, only to ask the API how long the shard *jobs* -# took: the part of a shard's cost that happens before its driver starts. -# -# Always exits zero: check results are the report's business, not the job -# status's -- only a genuinely broken collector fails this job. - -source(file.path( - dirname(sub("--file=", "", grep("^--file=", commandArgs(), value = TRUE))), - "util.R" -)) - -results_dir <- env_chr("RESULTS_DIR") -stopifnot(nzchar(results_dir)) -plan <- read_json(env_chr("PLAN", "plan.json")) -retry_dir <- env_chr("RETRY_DIR") -out_dir <- env_chr("OUT_DIR", "revdep") -baseline_out <- env_chr("BASELINE_OUT", "baseline") -timings_out <- env_chr("TIMINGS_OUT", "timings") - -dir.create(file.path(out_dir, "pkgs"), recursive = TRUE, showWarnings = FALSE) -dir.create( - file.path(baseline_out, "old-rds"), - recursive = TRUE, - showWarnings = FALSE -) -dir.create(timings_out, recursive = TRUE, showWarnings = FALSE) - -# ------------------------------------------------------------------- merge --- - -# Shard artifacts are named revdepx-results--; walking them in -# attempt order makes the later attempt win when a shard was re-run. -# -# `download-artifact` only creates the per-artifact subdirectory when it -# downloads more than one: a run planned into a single shard has its -# manifest.ndjson land directly in `results_dir`, not in -# `results_dir/revdepx-results-1-1/`. Run 31930350338 was that run, and the -# collector found one directory (`results/pkgs`), no manifest in it, and -# collected nothing -- then carried all 1011 results over from the run it was -# retrying and committed them as if they were fresh. So the layout is -# discovered rather than assumed: a shard directory is one that has a manifest. -attempt_of <- function(path) { - n <- suppressWarnings(as.integer(sub("^.*-", "", basename(path)))) - if (is.na(n)) 0L else n -} -has_manifest <- function(paths) { - paths[file.exists(file.path(paths, "manifest.ndjson"))] -} -shard_dirs <- has_manifest(list.dirs(results_dir, recursive = FALSE)) -shard_dirs <- shard_dirs[order(vapply(shard_dirs, attempt_of, integer(1)))] -shard_dirs <- c(has_manifest(results_dir), shard_dirs) - -entries <- list() -take <- function(entry, from) { - entry$carried <- isTRUE(entry$carried) - entries[[entry$package]] <<- entry - src <- file.path(from, "pkgs", entry$package) - if (dir.exists(src)) { - dest <- file.path(out_dir, "pkgs", entry$package) - unlink(dest, recursive = TRUE) - dir.create(dest, recursive = TRUE, showWarnings = FALSE) - file.copy(list.files(src, full.names = TRUE), dest, recursive = TRUE) - } -} - -shard_timings <- list() -for (dir in shard_dirs) { - timing <- file.path(dir, "timing.json") - if (file.exists(timing)) { - row <- tryCatch(read_json(timing), error = function(e) NULL) - if (!is.null(row$index)) { - shard_timings[[as.character(row$index)]] <- row - } - } - manifest <- file.path(dir, "manifest.ndjson") - if (!file.exists(manifest)) { - next - } - for (line in readLines(manifest, warn = FALSE)) { - if (nzchar(trimws(line))) { - take(jsonlite::fromJSON(line, simplifyVector = FALSE), dir) - } - } -} -inform( - "Collected ", - length(entries), - " package(s) from ", - length(shard_dirs), - " shard artifact(s)" -) - -# A retried run reports the whole picture: results the retry did not touch are -# carried over from the earlier run's report, marked as such. -if (nzchar(retry_dir) && file.exists(file.path(retry_dir, "manifest.json"))) { - carried <- 0L - for (entry in read_json(file.path(retry_dir, "manifest.json"))) { - if (is.null(entries[[entry$package]])) { - entry$carried <- TRUE - take(entry, retry_dir) - carried <- carried + 1L - } - } - inform( - "Carried ", - carried, - " untouched result(s) over from run ", - plan$retry_of - ) -} - -# A subset run -- `packages: broken`, an explicit list, a `part` -- reports -# the whole record too. The committed manifest is the durable record of every -# package the last full run checked, and writing this run's slice over it -# would shrink 3435 rows to 204 (run 32260705703 did exactly that): the -# repository would remember only what was just re-checked, and the next -# `packages: broken` would select from an amnesiac record. So rows for -# packages *outside this run's plan* are kept from the committed manifest, -# marked carried. Planned packages are deliberately not eligible: a planned -# package with no fresh result is a dead shard, and the `missing` fill below -# must say so rather than let a stale row paper over it. Entries are set -# directly, not through take(): the committed report has no pkgs/ payload to -# copy, and take() would unlink the destination it copies into. -committed_manifest <- file.path(out_dir, "manifest.json") -if ( - (!identical(plan$selection, "all") || !is.null(plan$part)) && - file.exists(committed_manifest) -) { - planned <- unlist(lapply(plan$shards %||% list(), function(shard) { - vapply( - shard$packages %||% list(), - function(p) p$name %||% "", - character(1) - ) - })) - kept <- 0L - for (entry in tryCatch( - read_json(committed_manifest), - error = function(e) list() - )) { - name <- entry$package %||% "" - if (!nzchar(name) || !is.null(entries[[name]]) || name %in% planned) { - next - } - entry$carried <- TRUE - entries[[name]] <- entry - kept <- kept + 1L - } - if (kept > 0) { - inform( - "Kept ", - kept, - " committed result(s) for packages outside this run's selection" - ) - } -} - -# Every package the plan named has to appear in the report, including the ones -# whose shard uploaded nothing at all: a job that dies -- runner failure, -# cancellation, the job timeout above the shard's own deadline -- takes its -# manifest with it, and a package silently absent from a report reads as one -# that was fine. `missing` is a not-ok result, so `retry-run` picks exactly -# these up, the same way it picks up a deferral. -missing <- 0L -for (shard in plan$shards %||% list()) { - for (p in shard$packages %||% list()) { - if (!is.null(entries[[p$name]])) { - next - } - entries[[p$name]] <- list( - package = p$name, - version = p$version, - level = p$level %||% 0L, - shard = shard$index, - weight_minutes = p$weight_minutes, - t_total = p$t_total %||% 0, - dep_fingerprint = p$dep_fingerprint, - baseline_planned = isTRUE(p$baseline), - # Matches the shard's own entry shape; `baseline_reused` was dropped when - # both halves became mandatory, and nothing sets it any more. - baseline_agrees = NA, - result = "missing", - status = "", - status_old = "", - status_new = "", - new_issues = 0L, - t_old = NA, - t_new = NA, - old_checked_at = NA, - message = sprintf( - "shard %s uploaded no result for this package; its job did not finish", - shard$index - ), - our_cran_version = plan$cran_version, - our_dev_version = plan$dev_version, - carried = FALSE - ) - missing <- missing + 1L - } -} -if (missing > 0) { - inform( - missing, - " planned package(s) have no result at all; reported as missing" - ) -} - -entries <- entries[order(names(entries))] -results_tbl <- vapply(entries, function(e) e$result, character(1)) - -# ---------------------------------------------------------------- manifest --- - -write_json( - list( - package = plan$package, - dev_version = plan$dev_version, - cran_version = plan$cran_version, - r_version = plan$r_version, - base_image = plan$base_image, - engine = plan$engine, - workflow = env_chr("GITHUB_WORKFLOW"), - sha = plan$sha, - run_id = env_chr("GITHUB_RUN_ID"), - retry_of = plan$retry_of, - generated_at = now_utc() - ), - file.path(out_dir, "run.json") -) -write_json(unname(entries), file.path(out_dir, "manifest.json")) - -# ---------------------------------------------------------------- baseline --- - -baseline <- list() -for (entry in entries) { - rds <- file.path(out_dir, "pkgs", entry$package, "old.rds") - if ( - !file.exists(rds) || - is.null(entry$old_checked_at) || - is.na(entry$old_checked_at) - ) { - next - } - file.copy( - rds, - file.path(baseline_out, "old-rds", paste0(entry$package, ".rds")) - ) - baseline[[length(baseline) + 1]] <- list( - package = entry$package, - version = entry$version, - our_cran_version = entry$our_cran_version, - r_version = plan$r_version, - # The container platform the old half ran under. plan.R refuses a row - # whose tag differs from its own -- which also walls off every - # revdep2-era baseline, none of which carry the field. - base_image = plan$base_image, - dep_fingerprint = entry$dep_fingerprint, - checked_at = entry$old_checked_at, - status_old = entry$status_old, - has_old = TRUE - ) -} -write_json(baseline, file.path(baseline_out, "baseline.json")) -inform("Baseline carries ", length(baseline), " old-version result(s)") - -# ----------------------------------------------------------------- timings --- - -# What this run cost, in the form the next plan can use: one row per package -# (seconds per check here, next to the seconds CRAN reports) and one per shard -# (install, check, script and job minutes, next to what was predicted). -# -# The plan's cost model is three constants -- how fast checks run here, what a -# shard costs before it checks anything, what one more dependency costs to -# install -- and every one of them is measurable. Measuring them is what keeps -# the shard count honest: a model that overestimates the work cuts it into more -# shards than the parallel capacity can run, and each extra shard is another -# setup paid for nothing. -# The canonical per-package number: the mean of the per-half durations that -# exist -- two real measurements, their honest middle. "seconds" answers -# "what does one half cost here", the unit the cost model doubles into a -# package's bill. (Rows from the retired pair engine carried the pair's -# shared wall clock in both fields, so their mean is that wall clock, and -# the unit still holds.) -seconds_of <- function(entry) { - both <- suppressWarnings(as.numeric(c(entry$t_old, entry$t_new))) - both <- both[!is.na(both) & both > 0] - if (length(both) == 0) { - NULL - } else { - list(seconds = mean(both), checks = length(both)) - } -} -package_rows <- list() -for (entry in entries) { - measured <- seconds_of(entry) - if (is.null(measured)) { - next - } - package_rows[[length(package_rows) + 1]] <- list( - package = entry$package, - version = entry$version, - t_total = entry$t_total %||% 0, - checks = measured$checks, - seconds = round(measured$seconds, 1) - ) -} - -# The shard's own clock covers install and checks; the minutes before its -# driver starts -- runner image, R, pandoc, TinyTeX, artifact downloads -- are -# only visible from the API, and they are precisely the price of one more -# shard. -job_minutes <- run_shard_job_minutes(env_chr("GITHUB_RUN_ID")) -shard_rows <- lapply(shard_timings, function(t) { - index <- as.character(t$index) - install <- ((t$restore_seconds %||% 0) + (t$install_seconds %||% 0)) / 60 - list( - index = t$index, - packages = t$packages %||% 0, - checks = t$checks %||% 0, - install_packages = t$install_packages %||% 0, - restored = t$restored %||% 0, - install_minutes = round(install, 2), - check_minutes = round((t$check_seconds %||% 0) / 60, 2), - script_minutes = round((t$script_seconds %||% 0) / 60, 2), - job_minutes = if (index %in% names(job_minutes)) { - round(unname(job_minutes[[index]]), 2) - } else { - NULL - }, - planned_minutes = t$planned_minutes, - planned_check_minutes = t$planned_check_minutes - ) -}) -shard_rows <- unname(shard_rows[order(as.numeric(names(shard_rows)))]) - -timings <- list( - run_id = env_chr("GITHUB_RUN_ID"), - generated_at = now_utc(), - # The engine stamps the run so calibration() can filter shard rows: per-half - # seconds pool across engines, shard setup and install medians do not. - engine = plan$engine, - r_version = plan$r_version, - platform = R.version$platform, - timing_flavor = plan$timing_flavor, - packages = package_rows, - shards = shard_rows -) -cal <- calibration(list(timings), plan$engine) -timings$calibration <- list( - check_scale = cal$check_scale, - setup_minutes = cal$setup_minutes, - install_seconds = cal$install_seconds -) -write_json(timings, file.path(timings_out, "timings.json")) -inform( - "Timings: ", - length(package_rows), - " package(s), ", - length(shard_rows), - " shard(s)", - if (length(job_minutes) == 0) " (job durations unavailable)" else "" -) - -# ----------------------------------------------------------------- reports --- - -# The report machinery is revdepcheck's own, fed through its `results` -# injection point; when the package is unavailable the manifest-derived -# summary below still stands on its own. -has_revdepcheck <- requireNamespace("revdepcheck", quietly = TRUE) - -comparison_of <- function(entry) { - dir <- file.path(out_dir, "pkgs", entry$package) - old_path <- file.path(dir, "old.rds") - new_path <- file.path(dir, "new.rds") - shim <- function(message) { - res <- revdepcheck:::rcmdcheck_error( - entry$package, - old = list(stdout = message, stderr = ""), - new = list(stdout = message, stderr = "") - ) - res$version <- entry$version - # `pkg_links()` reads the maintainer and the URL out of - # `result$new$description`, and `desc::desc(text = NULL)` falls back to the - # DESCRIPTION of the working directory -- which here is igraph's own. Left - # alone, a package that never got far enough to have a DESCRIPTION was - # reported with igraph's repository and igraph's maintainer address next to - # its name. A synthetic one carries no maintainer and no URL, so only the - # CRAN mirror link is emitted, and `[UNKNOWN]` is avoided as well. - res$new$version <- entry$version - res$new$description <- sprintf( - "Package: %s\nVersion: %s\n", - entry$package, - entry$version %||% "0" - ) - res$new$cran <- TRUE - res - } - if (!file.exists(old_path) || !file.exists(new_path)) { - # A row carried from the committed manifest has no check payload -- it is - # the record speaking, not this run. Shimming it as an error made - # revdepcheck classify it "failed to check": run 32281237129 carried - # 3402 ok rows and its README announced "Failed to check (3407)", with a - # 28-line "Not checked (ok)" failure section for every one of them. An - # ok row becomes a clean two-sided comparison instead -- status "+", - # zero rows -- which the summary counts and every table ignores. Carried - # not-ok rows keep the shim: "failed to check, not by this run" is the - # closest bucket the report vocabulary has for them, and their committed - # sections are protected separately. - if (isTRUE(entry$carried) && identical(entry$result, "ok")) { - clean_half <- function() { - structure( - list( - package = entry$package, - version = entry$version %||% "0", - rversion = "", - platform = "", - errors = character(), - warnings = character(), - notes = character(), - description = sprintf( - "Package: %s\nVersion: %s\n", - entry$package, - entry$version %||% "0" - ), - cran = TRUE, - bioc = FALSE, - checkdir = "", - install_out = "", - test_fail = list(), - timeout = FALSE - ), - class = "rcmdcheck" - ) - } - cmp <- tryCatch( - rcmdcheck::compare_checks(clean_half(), clean_half()), - error = function(e) NULL - ) - if (!is.null(cmp)) { - return(cmp) - } - } - message <- if (nzchar(entry$message %||% "")) { - entry$message - } else { - sprintf("Not checked (%s)", entry$result) - } - return(shim(message)) - } - tryCatch( - revdepcheck:::try_compare_checks( - entry$package, - readRDS(old_path), - readRDS(new_path) - ), - error = function(e) shim(conditionMessage(e)) - ) -} - -preamble <- c( - "# Platform", - "", - md_table(data.frame( - field = c("package", "dev", "CRAN", "commit", "R", "platform", "run", "date"), - value = c( - plan$package, - plan$dev_version, - plan$cran_version, - substr(plan$sha, 1, 9), - plan$r_version, - R.version$platform, - env_chr("GITHUB_RUN_ID", "local"), - format(Sys.Date()) - ) - )), - "" -) - -if (has_revdepcheck) { - results <- lapply(unname(entries), comparison_of) - names(results) <- names(entries) - - capture_report <- function(fun, ...) { - path <- tempfile() - fun(..., file = path) - readLines(path, warn = FALSE) - } - writeLines( - c( - preamble, - capture_report(revdepcheck::cloud_report_summary, pkg = ".", results = results) - ), - file.path(out_dir, "README.md") - ) - # `problems.md` and `failures.md` are assembled from one file per package - # rather than written whole. - # - # Two things fall out of that, and the second is why it was done. A diff - # names the package that changed instead of a line range in a file thousands - # of lines long. And a run only has to touch the packages it actually - # checked: a retry of 27 rewrites 27 files and leaves the other 984 exactly - # as the repository has them. Writing the file whole made every run restate - # the entire record, so a run that learnt nothing about a package could still - # rewrite that package's section -- from a shim, if its check output had not - # survived the trip. - # - # Empty is revdepcheck's own wording, so an assembled file with no sections - # reads the way the single-call version did. - no_problems <- "*Wow, no problems at all. :)*" - sections <- list( - problems = revdepcheck::cloud_report_problems, - failures = revdepcheck::cloud_report_failures - ) - for (dir in names(sections)) { - dir.create(file.path(out_dir, dir), showWarnings = FALSE) - } - - # revdepcheck emits one `# ()` block per package that its - # predicate selects, and the sentence above when it selects none. Asking it - # about a single package therefore yields exactly that package's section, or - # nothing. - section_of <- function(fun, package) { - lines <- capture_report(fun, pkg = ".", results = results[package]) - if (identical(trimws(paste(lines, collapse = "")), no_problems)) { - NULL - } else { - lines - } - } - - # A package whose check errors under *both* versions. `ok` is the verdict -- - # there is no new problem, which is what this workflow is for -- but the - # package is broken, and a section someone put in the report for it is not - # made stale by a run that reproduces the breakage on both sides. 79 of run - # 31930350338's 984 `ok` results are of this shape; 55 of them never got as - # far as a check at all (their dependencies would not install, so both - # halves stopped at `checking package dependencies` in a couple of seconds - # and agreed), and 24 are real checks of genuinely broken packages. - # - # This only declines to *delete*; nothing is added. revdepcheck's - # `problems.md` is the newly-broken list by design, and widening it to - # "still broken" is `all = TRUE` and a different report. - # `ok` specifically: a `newly_broken` package can error on both sides too -- - # archeofrag went 1E to 2E in run 31930350338 -- and that one was checked - # here, so its section is this run's to rewrite. - still_broken <- function(entry) { - e <- function(status) { - n <- regmatches( - status %||% "", - regexpr("^[0-9]+(?=E)", status %||% "", perl = TRUE) - ) - length(n) > 0 && as.integer(n) > 0 - } - identical(entry$result, "ok") && e(entry$status_old) && e(entry$status_new) - } - - # What this run is entitled to overwrite. A carried result is one this run - # never checked, and `missing` and `deferred` mean the shard did not get to - # it -- in all three cases the committed section is better evidence than - # anything reconstructible here. The `file.exists` clause makes that a - # preference rather than a rule: with no section on disk there is nothing to - # protect, so it is written from the comparison like any other. - keeps_committed <- function(entry, dir) { - (isTRUE(entry$carried) || - entry$result %in% c("missing", "deferred", "depmissing") || - still_broken(entry)) && - file.exists(file.path(out_dir, dir, paste0(entry$package, ".md"))) - } - - written <- setNames(integer(length(sections)), names(sections)) - for (entry in entries) { - # A carried row without a check payload has nothing to render a section - # from -- its committed section, where one exists, is already on disk - # and is better evidence than any shim. This run neither writes nor - # deletes for it. (Retry-carried rows are untouched by this: take() - # copied their payloads, so old.rds exists.) - if ( - isTRUE(entry$carried) && - !file.exists(file.path(out_dir, "pkgs", entry$package, "old.rds")) - ) { - next - } - for (dir in names(sections)) { - if (keeps_committed(entry, dir)) { - next - } - path <- file.path(out_dir, dir, paste0(entry$package, ".md")) - lines <- section_of(sections[[dir]], entry$package) - if (is.null(lines)) { - unlink(path) - } else { - writeLines(lines, path) - written[[dir]] <- written[[dir]] + 1L - } - } - } - - # Sorted by file name, so the assembled order is the package order rather - # than however the shards happened to be cut -- case-insensitively, which is - # the order revdepcheck's own single-call version produced and therefore the - # order the committed report is already in. Sorting the raw names instead - # moves `ECoL`, `GoodFitSBM`, `MetaNet` and `R6causal` to the front of - # `problems.md` and rewrites the whole file for nothing. - # - # `method = "radix"` on a lowercased key rather than plain `sort()`: the - # latter collates in the runner's locale, so the committed order would - # depend on where the collector happened to run. Radix is C collation, and - # C collation of the lowercased name is exactly the case-insensitive order. - # The file name is the tie-break, so the sort is total. - for (dir in names(sections)) { - files <- list.files( - file.path(out_dir, dir), - pattern = "[.]md$", - full.names = TRUE - ) - files <- files[ - order(tolower(basename(files)), basename(files), method = "radix") - ] - writeLines( - if (length(files) == 0) { - no_problems - } else { - unlist(lapply(files, readLines, warn = FALSE), use.names = FALSE) - }, - file.path(out_dir, paste0(dir, ".md")) - ) - inform( - dir, - ".md: ", - length(files), - " package(s), ", - written[[dir]], - " written by this run" - ) - } - - writeLines( - capture_report( - revdepcheck::revdep_report_cran, - pkg = ".", - results = results - ), - file.path(out_dir, "cran.md") - ) - inform("Reports written to ", out_dir) -} else { - inform( - "revdepcheck is not installed; writing the manifest-derived summary only" - ) - df <- data.frame( - package = names(entries), - version = vapply(entries, function(e) e$version %||% "?", character(1)), - result = results_tbl, - old = vapply(entries, function(e) e$status_old %||% "", character(1)), - new = vapply(entries, function(e) e$status_new %||% "", character(1)) - ) - writeLines( - c(preamble, "# Revdeps", "", md_table(df)), - file.path(out_dir, "README.md") - ) -} - -# ------------------------------------------------------------------ summary -- - -tally <- function(what) sum(results_tbl == what) -not_ok <- sum(results_tbl != "ok") - -# Whether this report is worth writing over the committed one. -# -# The report in `revdep/` is the repository's record, and `packages: broken` -# reads it back to decide what to re-check. A run in which nothing produced a -# comparison -- every shard dead, every package `missing`, a bad plan, a driver -# bug that turned the whole set into `depfail` -- would replace that record with -# a list of things it never learnt anything about, and there is no way back to -# it. So the run says out loud whether it compared anything at all, and the -# workflow gates the commit on that; the artifact is uploaded either way, so -# nothing is hidden, only the destructive step is skipped. -# -# Only *this run's* comparisons count. A retry whose shards all died still -# carries the donor run's good results (`carried = TRUE`) -- that is the -# retry contract -- but they are the donor's learning, not this run's, and a -# gate they could pass would let the exact run this gate exists for (learnt -# nothing, every fresh package `missing`) overwrite the record after all. -compared <- sum(vapply( - entries, - function(e) { - !isTRUE(e$carried) && (e$result %in% c("ok", "newly_broken")) - }, - logical(1) -)) -set_output("compared", compared) -if (compared == 0) { - inform( - "No package produced a comparison; the report is written and uploaded, ", - "but the committed one is left alone" - ) -} - -# The one sentence a reader needs, before any table. -headline <- if (tally("newly_broken") > 0) { - sprintf( - "**%d of %d packages newly broken.**", - tally("newly_broken"), - length(entries) - ) -} else if (not_ok > 0) { - sprintf( - "No new breakage; %d of %d packages could not be fully checked.", - not_ok, - length(entries) - ) -} else { - sprintf("All good: no new problems in %d packages.", length(entries)) -} - -counts_df <- data.frame( - Result = c( - "ok", "newly broken", "failed to check", - # Its own row, and deliberately not counted as a failure: a check killed - # by the clock says nothing about the package, and in the old half it says - # nothing about our change either. - "timed out, not checked", - "dependencies not installable", - # `R CMD check` refused to start, in both halves, because something the - # package needs is not installed. Its own row rather than a failure: the - # package is not broken, it is unknown. - "dependencies unavailable to R CMD check", - "shard error", "deferred", - "no result from its shard" - ), - Packages = c( - tally("ok"), tally("newly_broken"), tally("failed"), - tally("timeout"), - tally("depfail"), tally("depmissing"), tally("error"), tally("deferred"), - tally("missing") - ) -) -counts_df <- counts_df[counts_df$Packages > 0 | counts_df$Result == "ok", ] - -# Packages that produced no comparison at all. revdepcheck lists them too, but -# only as bare names under "Failed to check" -- no version it could resolve and -# no reason, because the shim it is fed carries neither. The manifest has both, -# so that section is dropped from the embedded report and this table takes its -# place. -unchecked <- Filter( - function(e) !e$result %in% c("ok", "newly_broken"), - unname(entries) -) -reason_of <- function(e) { - message <- gsub("[[:space:]]+", " ", trimws(e$message %||% "")) - # Results carried over from an older run predate the shard recording one. - if (!nzchar(message) && nzchar(e$status %||% "")) { - message <- status_message(e$status) - } - if (nzchar(message)) { - return(message) - } - switch( - e$result, - deferred = "the shard hit its deadline before this package was checked", - depfail = "dependencies could not be installed", - depmissing = "R CMD check stopped at `checking package dependencies` under both versions", - missing = "its shard uploaded no results; the job did not finish", - sprintf("no reason recorded (result `%s`)", e$result) - ) -} -unchecked_df <- data.frame( - Package = vapply(unchecked, function(e) cran_link(e$package), character(1)), - Version = vapply(unchecked, function(e) e$version %||% "?", character(1)), - Result = vapply(unchecked, function(e) e$result, character(1)), - Shard = vapply( - unchecked, - function(e) as.character(e$shard %||% ""), - character(1) - ), - Old = vapply(unchecked, function(e) e$status_old %||% "", character(1)), - New = vapply(unchecked, function(e) e$status_new %||% "", character(1)), - Reason = vapply(unchecked, reason_of, character(1)) -) - -# The report itself, nested under this section: headings demoted two levels, -# and the platform preamble dropped -- the sentence above already says what -# was compared against what. -readme <- readLines(file.path(out_dir, "README.md"), warn = FALSE) -revdeps_at <- grep("^# Revdeps", readme)[1] -if (!is.na(revdeps_at)) { - readme <- readme[seq(revdeps_at, length(readme))] -} -readme <- drop_section(readme, "^## Failed to check") -# revdepcheck's tables link into the sibling report files, which is right -# inside the artifact and wrong here: a job summary is served from the run's -# own URL, where `problems.md#pkg` resolves to /actions/runs/problems.md and -# 404s. The package's CRAN page is the reachable equivalent; where the details -# actually live is said once, below. -# Only where the link text is a package name -- the anchor form revdepcheck -# emits for a package. A bare `[failures.md](failures.md)` is a link to the -# report's own file, and rewriting it to `package=failures.md` was nonsense. -readme <- gsub( - "\\[([a-zA-Z][a-zA-Z0-9.]*)\\]\\([^)]*[.]md(#[^)]*)?\\)", - "[\\1](https://cran.r-project.org/package=\\1)", - readme -) -readme <- gsub("^(#+)(\\s)", "##\\1\\2", readme) - -run_id <- env_chr("GITHUB_RUN_ID") -append_summary(c( - "## revdepx results", - "", - sprintf( - "`%s` %s (dev) vs %s (CRAN), R %s%s.", - plan$package, plan$dev_version, plan$cran_version, plan$r_version, - if (has_run(plan$retry_of)) { - sprintf(", retry of run %s", run_link(plan$retry_of)) - } else { - "" - } - ), - "", - headline, - "", - md_table(counts_df), - "", - readme, - "", - if (nrow(unchecked_df) > 0) { - # A run where everything defers would put every revdep in this table; the - # summary has a size limit, and losing it whole is worse than a cut list. - shown <- utils::head(unchecked_df, 200) - c( - sprintf("### Could not be checked (%d)", nrow(unchecked_df)), - "", - paste( - "No comparison was produced for these, so they say nothing about the", - "dev version either way. The shard job named in `Shard` has the full", - "check log for each." - ), - "", - md_table(shown), - if (nrow(shown) < nrow(unchecked_df)) { - c("", sprintf( - "... and %d more; the full list is `manifest.json` in the report artifact.", - nrow(unchecked_df) - nrow(shown) - )) - }, - "" - ) - }, - # What the run cost, in the terms the next plan is sized in. A plan that - # overestimates buys shards it cannot run in parallel, so these three numbers - # are worth showing next to the results they came from. - if (length(package_rows) > 0 || length(shard_rows) > 0) { - or_unmeasured <- function(x, fmt, ...) { - if (is.null(x)) "not measured" else sprintf(fmt, x, ...) - } - # From the package rows, not the shard rows: a shard whose job died leaves - # no timing of its own, but the checks it did finish are still in the - # manifest the collector just merged. - # `seconds` is the canonical per-half number; `checks` says how many - # halves it stands for. Summing seconds is half the check wall clock, - # which the planned-vs-actual shard table already reports exactly. Close - # enough for a cost headline. - check_minutes <- sum(vapply( - package_rows, - function(p) p$seconds / 60, - numeric(1) - )) - job <- vapply( - shard_rows, - function(s) s$job_minutes %||% NA_real_, - numeric(1) - ) - c( - "### What this run cost", - "", - md_table(data.frame( - Measured = c( - "Checks", - "Check speed", - "Shard jobs", - "Setup per shard", - "Install per dependency" - ), - Value = c( - sprintf( - "%d in %d package(s), ~%.0f min", - sum(vapply(package_rows, function(p) p$checks, numeric(1))), - length(package_rows), - check_minutes - ), - or_unmeasured(cal$check_scale, "%.2f x the time CRAN reports"), - if (all(is.na(job))) { - sprintf("%d shard(s), job durations unavailable", length(shard_rows)) - } else { - sprintf( - "%d shard(s), median ~%.0f min, longest ~%.0f min", - length(shard_rows), - stats::median(job, na.rm = TRUE), - max(job, na.rm = TRUE) - ) - }, - or_unmeasured( - cal$setup_minutes, - "~%.1f min before the driver starts" - ), - or_unmeasured(cal$install_seconds, "~%.1f s") - ), - check.names = FALSE - )), - "", - sprintf( - "The next plan of either workflow reads these from the `revdepx-timings` artifact of %s and sizes its shards with them.", - this_run_link("this run") - ), - "" - ) - }, - "### Getting the results", - "", - sprintf( - "The full report -- `problems.md`, `failures.md`, `cran.md` and every check's output -- is the `revdepx-report` artifact of %s.", - this_run_link("this run") - ), - "", - "```sh", - sprintf("gh run download %s --name revdepx-report --dir revdep/", run_id), - "# retry everything that is not ok:", - sprintf( - "gh workflow run %s -f retry-run=%s", - local({ - ref <- env_chr("GITHUB_WORKFLOW_REF") - file <- basename(sub("@.*$", "", ref)) - if (nzchar(file) && grepl("[.]ya?ml$", file)) file else "revdep4.yaml" - }), - run_id - ), - "```" -)) - -inform( - length(entries), - " package(s): ", - sum(results_tbl == "ok"), - " ok, ", - not_ok, - " with findings -- see the summary and the revdepx-report artifact" -) diff --git a/.github/workflows/revdepx/compare.R b/.github/workflows/revdepx/compare.R deleted file mode 100644 index 7ed3217..0000000 --- a/.github/workflows/revdepx/compare.R +++ /dev/null @@ -1,552 +0,0 @@ -# The comparison layer: from two `R CMD check` halves to one manifest line. -# -# Extracted from revdep2's shard.R. The queue engine (revdep4) sources it -# into compare-one.R, one short-lived process per package, run by a worker the -# moment that package's halves are done. Everything here is a plain function -# of its arguments -- no shard state, no globals -- and what a function learns -# comes back as a named list of manifest-field updates for the caller to apply -# its own way: the shard driver folds them into its per-package state, -# compare-one.R into the one entry it is about to write. -# -# Sourced after util.R, which provides `%||%`, `inform`, `now_utc` and the -# result-label helpers. Base R plus jsonlite; rcmdcheck is reached lazily. - -# The one-line "0E 0W 0N" summary of an rcmdcheck object -- what the manifest -# columns, the baseline drift check and the depmissing guard all compare. -counts <- function(x) { - if (!inherits(x, "rcmdcheck")) { - return("?") - } - sprintf( - "%dE %dW %dN", - length(x$errors), - length(x$warnings), - length(x$notes) - ) -} - -# The check log with this run's incidentals taken out of it. -# -# Under revdep2 the two halves ran on the host against libraries and check -# directories whose paths differed by construction, and neutralising those -# paths was load-bearing. Inside the containers neither differs: both halves -# see the half library at /revdepx/lib-half, the baked dependency library at -# /opt/revdepx/lib, and the check directory at /revdepx/out -- literally -# identical strings on both sides, by construction. Replacing them anyway is -# belt and braces: three fixed-string substitutions keep a log that leaks one -# of them some other way (a package printing its own `.libPaths()`, say) from -# ever fabricating a difference. -# -# The timings still earn their keep. `--as-cran` sets `_R_CHECK_TIMINGS_`, so -# every stage slower than ten seconds prints its own `[user/elapsed]` pair, -# and no two checks ever agree on those -- under revdep2 a run of rphylopic -# against the *same* igraph on both sides differed in exactly two lines: the -# log directory, and `[14s/12s]` against `[13s/11s]`. The workflow suppresses -# the stamps at the source (`_R_CHECK_TIMINGS_=""`); blanking them here is the -# second line of defence. -# -# Both matter twice. `compare_checks()` matches issues by their text, so a -# difference in the first line of an issue makes an issue both halves have -# look like a new one; and the diff between the halves is only worth printing -# if two identical results produce an empty one. Nothing else is touched: a -# difference anywhere but here is exactly what this workflow exists to find. -neutral_log <- function(path) { - lines <- readLines(path, warn = FALSE) - for (from in c("/revdepx/out", "/revdepx/lib-half", "/opt/revdepx/lib")) { - lines <- gsub(from, "", lines, fixed = TRUE) - } - # `[14s/12s]`, and the one-number form R uses where it has only one. - gsub("\\[[0-9.]+s(/[0-9.]+s)?\\]", "[]", lines) -} - -# The two halves' check logs, as a patch. -# -# Both sides are neutralised first, so the stage timings (and any leaked -# paths) are gone and what is left is the package: an empty diff means the dev -# version changed nothing about this check, however long the log. The scratch -# files live in the package's own work directory rather than `tempdir()`, -# which keeps them apart under either engine. -check_diff <- function(name, old_log, new_log, work_dir) { - tmp <- file.path( - work_dir, - paste0(name, c("-old-00check.log", "-new-00check.log")) - ) - writeLines(neutral_log(old_log), tmp[[1]]) - writeLines(neutral_log(new_log), tmp[[2]]) - on.exit(unlink(tmp), add = TRUE) - suppressWarnings(system2( - "diff", - shQuote(c("-u", "--label", "old", tmp[[1]], "--label", "new", tmp[[2]])), - stdout = TRUE, - stderr = NULL - )) -} - -# One half's result, read off the files its check container left behind. -# -# `rcmdcheck::parse_check()` turns a 00check.log into the same object -# `rcmdcheck()` used to return, so everything downstream -- the counts, -# `compare_checks()`, the manifest -- is unchanged. The timeout is coreutils', -# inside the container, which is what makes the distinction reliable: exit 124 -# is the deadline, anything else is the check saying something. -# -# `duration` is this half's own clock in seconds: the queue runs the two -# halves one after the other, so each is a real measurement. (Rows written by -# the retired pair engine carried the pair's shared wall clock instead.) -read_side <- function(work_dir, phase, name, timeout_sec, duration) { - dir <- file.path(work_dir, phase) - # A half that never wrote its status -- an unwritable work directory, a - # full disk, the check script dying before its last line -- used to throw - # "subscript out of bounds" out of the whole loop. It is one package's - # problem, so it reads as one. - status <- tryCatch( - suppressWarnings(as.integer(readLines( - file.path(dir, "status"), - warn = FALSE - )[[1]])), - error = function(e) NA_integer_ - ) - log <- file.path(dir, paste0(name, ".Rcheck"), "00check.log") - # check-half.sh leaves an `oom` marker when docker reports the container - # was OOM-killed. "Timed out" and "produced no readable result" both read - # very differently when the real story is the memory limit, so the marker - # goes into the message where a reader will meet it. - oom <- if (file.exists(file.path(dir, "oom"))) { - " (container hit its memory limit)" - } else { - "" - } - result <- if (identical(status, 124L)) { - simpleError(sprintf( - "%s check timed out after %ds%s", - phase, - round(timeout_sec), - oom - )) - } else { - tryCatch( - { - # Parsed twice, on purpose. `parse_check()` reads `00install.out` - # and the test transcripts off the check directory it finds named in - # the log's first line -- so parsing the *neutralised* text alone, - # where that path has been replaced by a constant, silently leaves - # `install_out` at "<00install.out file does not exist>" and - # `test_fail` empty, and revdepcheck's failures.md loses exactly the - # output a reader opens it for. So the real file gives the object, - # and the neutralised text gives only the three fields that are - # compared and diffed, where the paths and stage timings would - # otherwise make two identical halves look different. - res <- rcmdcheck::parse_check(log) - neutral <- rcmdcheck::parse_check(text = neutral_log(log)) - res$errors <- neutral$errors - res$warnings <- neutral$warnings - res$notes <- neutral$notes - # Who to tell about a broken package. - # - # revdepcheck's reports head each package with its own GitHub, its - # maintainer's email and its CRAN mirror, and it reads all three out - # of `$description` and `$cran` on the result. `rcmdcheck()` filled - # those in because it had the package's source; `parse_check()` - # cannot know them from a log, so every entry in problems.md came out - # as "* : " once the driver switched. The check directory has - # the installed DESCRIPTION sitting in it, and every package here is - # from CRAN by construction. - described <- file.path(dirname(log), name, "DESCRIPTION") - if (file.exists(described)) { - res$description <- paste( - readLines(described, warn = FALSE), - collapse = "\n" - ) - } - res$cran <- TRUE - # `parse_check()` looks for 00install.out under the path in the log's - # first line -- the *container's* path, which no host filesystem has - # -- so `install_out` came out as "<00install.out file does not - # exist>" for every install failure, and failures.md showed a reader - # everything except the compiler error they opened it for. The real - # file sits next to the log; the tail is kept, because that is where - # a compile error ends up and whole Stan build transcripts run to - # thousands of lines. - install_log <- file.path(dirname(log), "00install.out") - if (file.exists(install_log)) { - lines <- readLines(install_log, warn = FALSE) - keep <- 200L - if (length(lines) > keep) { - lines <- c( - sprintf( - "[... %d earlier lines omitted; the full 00install.out is in the check artifact ...]", - length(lines) - keep - ), - utils::tail(lines, keep) - ) - } - res$install_out <- paste(lines, collapse = "\n") - } - res - }, - error = function(e) { - simpleError(sprintf( - "%s check produced no readable result (exit %s): %s%s", - phase, - status, - conditionMessage(e), - oom - )) - } - ) - } - attr(result, "duration") <- duration - attr(result, "timed_out") <- identical(status, 124L) - # Where it was when the clock ran out. A check killed in `tests` is a - # different animal from one killed while compiling, and the report used to - # say only "timed out". - attr(result, "last_step") <- if (file.exists(log)) { - steps <- grep("^[*] ", readLines(log, warn = FALSE), value = TRUE) - if (length(steps) > 0) utils::tail(steps, 1) else "" - } else { - "" - } - # The kernel killing one compiler inside the container kills neither the - # container (docker reports no OOM -- the `oom` marker stays absent) nor - # the check, which completes and reports "installation failed". Run - # 32158907637's dmesg watch caught two cc1plus processes killed at the - # per-check cap exactly this way, while the manifest said only "fails to - # install". The giveaway lines land in 00install.out, so they become an - # attribute the comparison can put into the message. - install_log <- file.path(dir, paste0(name, ".Rcheck"), "00install.out") - attr(result, "compiler_killed") <- tryCatch( - file.exists(install_log) && - any(grepl( - paste0( - "Killed signal terminated program", - "|internal compiler error: Killed", - "|virtual memory exhausted", - "|signal: 9, SIGKILL", - "|Cannot allocate memory" - ), - readLines(install_log, warn = FALSE), - useBytes = TRUE - )), - error = function(e) FALSE - ) - result -} - -# This package's directory in the results artifact, created on first use. -pkg_out <- function(pkgs_dir, name) { - dir <- file.path(pkgs_dir, name) - dir.create(dir, recursive = TRUE, showWarnings = FALSE) - dir -} - -# The files worth carrying out of a check directory: what broke, and the -# complete transcripts of the two stages that explain why. The half's -# `driver.log` and `status` ride along from next to the .Rcheck directory: -# the driver log is the per-stage timing record -- check-half.sh stamps -# every line with elapsed seconds, precisely because `_R_CHECK_TIMINGS_` -# stays off to keep the compared check logs stable -- and before this it -# died with the runner's work directory, which made the yaml's "nothing is -# lost" claim quietly false. -copy_check_output <- function(rcheck, keep) { - dir.create(keep, recursive = TRUE, showWarnings = FALSE) - for (f in c( - "00check.log", - "00install.out", - list.files( - rcheck, - pattern = "[.]Rout[.]fail$|-Ex[.]Rout$", - recursive = TRUE - ) - )) { - if (file.exists(file.path(rcheck, f))) { - file.copy( - file.path(rcheck, f), - file.path(keep, basename(f)), - overwrite = TRUE - ) - } - } - for (f in c("driver.log", "status")) { - if (file.exists(file.path(dirname(rcheck), f))) { - file.copy( - file.path(dirname(rcheck), f), - file.path(keep, f), - overwrite = TRUE - ) - } - } -} - -# Salvage a half that produced no readable result -- a timeout, an OOM kill, -# a container that never started. What survives varies: a killed check -# leaves a partial .Rcheck whose 00check.log lists every stage it finished, -# a container that never ran leaves only the driver log -- and -# copy_check_output() copies whatever of that exists, including the stamped -# driver log, so the post-mortem of exactly these packages stops depending -# on a work directory that dies with the runner. Run 33777134786's -# both-halves timeouts (ctmm, E2E, PortfolioTesteR) salvaged nothing at -# all; "where did the 1800 seconds go" had no answer in any artifact. -salvage_side <- function(work_dir, pkgs_dir, name, phase) { - copy_check_output( - file.path(work_dir, phase, paste0(name, ".Rcheck")), - file.path(pkg_out(pkgs_dir, name), paste0(phase, "-check")) - ) -} - -# Record the half that did produce a result, when its partner did not. -# -# There is nothing to compare, so there is no verdict -- but the check ran, -# and what it found is the only thing anyone will have to go on when they come -# back to the package. Kept where the comparison path keeps it, so `retry-run` -# and a human reading the artifact find it in the usual place. Returns the -# manifest fields it learnt, like everything here, for the caller to apply. -keep_side <- function(work_dir, pkgs_dir, name, phase, result) { - saveRDS(result, file.path(pkg_out(pkgs_dir, name), paste0(phase, ".rds"))) - copy_check_output( - file.path(work_dir, phase, paste0(name, ".Rcheck")), - file.path(pkgs_dir, name, paste0(phase, "-check")) - ) - if (identical(phase, "old")) { - list( - status_old = counts(result), - t_old = attr(result, "duration"), - old_checked_at = now_utc() - ) - } else { - list( - status_new = counts(result), - t_new = attr(result, "duration") - ) - } -} - -# A half that errored or timed out, turned into this package's verdict. -# Returns the manifest-field updates; the log line is printed here so every -# caller says it the same way (`progress` is an optional position note). -check_failure <- function(name, phase, result, progress = "") { - note <- if (nzchar(progress)) paste0(", ", progress) else "" - if (isTRUE(attr(result, "timed_out"))) { - # `timeout`, not `failed`. A check killed by the clock says nothing about - # the package, and in the old phase it says nothing about our change - # either -- the dev version is not even on that library path. Reporting it - # as a failure put 60 packages into failures.md in run 31304411628 that - # the run had learnt nothing about. `needs_recheck()` picks it up either - # way, so `retry-run` still re-checks them. - step <- attr(result, "last_step") %||% "" - inform( - name, - ": ", - phase, - " check timed out (", - attr(result, "duration"), - "s)", - if (nzchar(step)) paste0(" at ", trimws(step)) else "", - note - ) - list( - result = "timeout", - message = sprintf( - "%s check timed out after %ds%s", - phase, - attr(result, "duration"), - if (nzchar(step)) paste0(", at: ", trimws(step)) else "" - ) - ) - } else { - inform( - name, - ": ", - phase, - " check errored: ", - conditionMessage(result), - note - ) - list(result = "error", message = conditionMessage(result)) - } -} - -# Two parsed halves into one verdict: the tail of revdep2's per-package flow, -# as a plain function. -# -# Both halves are always fresh checks. Where the plan certified a stored old -# result as comparable (`baseline_planned`), it is read back purely as a -# *second opinion*: `baseline_agrees` records whether the fresh old check -# reproduced it, and a disagreement is printed as drift. It never substitutes -# for the check itself -- revdep2 tried that once, and 76 of run -# 31879790285's 78 `newly_broken` verdicts were false; the identical -# container platform would make substitution far safer now, but a fresh old -# is the only result whose provenance this run fully controls, so the stored -# one is kept in the advisory seat. Returns the manifest-field updates -- -# result, status, status_old, status_new, new_issues, t_old, t_new, -# old_checked_at, message, baseline_agrees -- whichever of them this -# package's comparison decided. -compare_halves <- function( - name, - old, - new, - pkgs_dir, - baseline_dir = NULL, - baseline_planned = FALSE -) { - updates <- list() - - saveRDS(old, file.path(pkg_out(pkgs_dir, name), "old.rds")) - updates$status_old <- counts(old) - updates$t_old <- attr(old, "duration") - updates$old_checked_at <- now_utc() - - # The second opinion: if the stored result disagrees with what the old - # check just produced under identical conditions -- same base image, same - # dependency fingerprint, or the plan would not have offered it -- that is - # drift worth recording and printing, wherever it comes from (a flaky test, - # a moved system library, this harness). - if (isTRUE(baseline_planned) && !is.null(baseline_dir)) { - rds <- file.path(baseline_dir, "old-rds", paste0(name, ".rds")) - baseline <- tryCatch(readRDS(rds), error = function(e) NULL) - if (!is.null(baseline)) { - agrees <- identical(counts(baseline), counts(old)) - updates$baseline_agrees <- agrees - if (!agrees) { - inform(sprintf( - "%s: the baseline said %s, the old check now says %s", - name, - counts(baseline), - counts(old) - )) - } - } - } - - saveRDS(new, file.path(pkg_out(pkgs_dir, name), "new.rds")) - - cmp <- tryCatch( - rcmdcheck::compare_checks(old, new), - error = function(e) NULL - ) - if (is.null(cmp)) { - updates$result <- "failed" - updates$status_new <- counts(new) - updates$t_new <- attr(new, "duration") - updates$message <- - "both checks ran, but their results could not be compared" - } else if (aborted_on_dependencies(new) && aborted_on_dependencies(old)) { - # Neither half ran. `compare_checks()` still says `+` -- the two agree, - # and they agree on having done nothing -- so without this the package is - # reported `ok`. It is not ok, it is unknown, and `needs_recheck()` picks - # `depmissing` up so a retry with those repositories enabled re-checks - # it. 55 of run 31930350338's `ok` results were this. - absent <- missing_dependencies(new) - updates$result <- "depmissing" - updates$status <- cmp$status - updates$status_new <- counts(new) - updates$t_new <- attr(new, "duration") - updates$new_issues <- 0L - updates$message <- paste0( - "R CMD check stopped at `checking package dependencies` under both ", - "versions; nothing was checked", - if (length(absent) > 0) { - paste0(" (not installed: ", paste(absent, collapse = ", "), ")") - } - ) - } else { - new_issues <- sum(cmp$cmp$change == 1) - updates$result <- classify_status(cmp$status, new_issues) - updates$status <- cmp$status - updates$status_new <- counts(new) - # This half's measured seconds -- each half's true clock under the - # queue. It used to be recorded only where the comparison failed, which - # left `t_new` null for every package that compared -- that is, for all - # of them. - updates$t_new <- attr(new, "duration") - updates$new_issues <- new_issues - # An install failure or a timeout leaves nothing to compare, so the - # result is only "failed"; say which one it was. - updates$message <- status_message(cmp$status) - # "Fails to install" reads as the package's fault; a compiler the kernel - # OOM-killed under the per-check cap is this harness's. Name it, so the - # reader reaches for REVDEPX_MEMORY_PER_CHECK instead of the maintainer. - if ( - startsWith(cmp$status, "i") && - (isTRUE(attr(new, "compiler_killed")) || - isTRUE(attr(old, "compiler_killed"))) - ) { - updates$message <- paste0( - updates$message, - "; the compiler was killed inside the container -- ", - "likely the per-check memory cap (REVDEPX_MEMORY_PER_CHECK)" - ) - } - } - updates -} - -# The template for one package's manifest line: every field, initialised to -# the truthful defaults -- `deferred` until something better is known. The -# shard driver keeps one of these per package and mutates it as results -# arrive; the queue engine's compare-one.R builds one, applies the updates -# `compare_halves()` (or `keep_side()`/`check_failure()`) returned, and -# writes the line, all in one short-lived process. -manifest_entry_defaults <- function(name, plan_pkg, shard_index) { - list( - package = name, - version = plan_pkg$version, - level = plan_pkg$level %||% 0L, - shard = shard_index, - weight_minutes = plan_pkg$weight_minutes, - t_total = plan_pkg$t_total %||% 0, - dep_fingerprint = plan_pkg$dep_fingerprint, - baseline_planned = isTRUE(plan_pkg$baseline), - # Whether the old check reproduced the baseline this run was offered. - # NA when there was none to compare against. - baseline_agrees = NA, - result = "deferred", - status = "", - status_old = "", - status_new = "", - new_issues = 0L, - t_old = NA, - t_new = NA, - old_checked_at = NA, - message = "" - ) -} - -# One manifest line, appended as soon as the package has one. -# -# The versions are stamped at write time, so even a line written on an error -# path names the versions it would have compared. Appended under `flock` when -# there is one: the queue has many writers, every worker's compare-one.R -# appending its package's line the moment it is done and the driver -# appending the deferred tail after the queue drains. One -# short O_APPEND write per line would probably never tear; the lock costs -# nothing and turns probably into does not. Where flock does not exist (it is -# util-linux, so everywhere this runs in CI, but a local macOS invocation -# counts) the plain append is what there is. -write_manifest_line <- function( - entry, - path, - our_cran_version, - our_dev_version -) { - entry$our_cran_version <- our_cran_version - entry$our_dev_version <- our_dev_version - line <- as.character(jsonlite::toJSON( - entry, - auto_unbox = TRUE, - null = "null" - )) - if (nzchar(Sys.which("flock"))) { - system2( - "flock", - c( - shQuote(paste0(path, ".lock")), - "-c", - shQuote(paste0("cat >> ", shQuote(path))) - ), - input = line - ) - } else { - cat(line, "\n", sep = "", file = path, append = TRUE) - } - invisible(entry) -} diff --git a/.github/workflows/revdepx/fetch.sh b/.github/workflows/revdepx/fetch.sh deleted file mode 100755 index f9c2e1a..0000000 --- a/.github/workflows/revdepx/fetch.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/sh -# Fetch the results of a revdepx run into revdep/ and show the summary. -# -# Usage: -# .github/workflows/revdepx/fetch.sh [] [

] -# -# Without a run id, the newest completed revdep4.yaml run is used. Needs the `gh` CLI, authenticated for the repository. - -set -eu - -run="${1:-}" -dir="${2:-revdep}" - -if [ -z "${run}" ]; then - # Both engines' runs, newest first; a repository that only has one of the - # two workflows is the normal case while the other PR is unmerged, so a - # workflow that gh cannot list is skipped, not fatal. - run="$( - for wf in revdep4.yaml; do - gh run list --workflow "${wf}" --limit 20 \ - --json databaseId,status,createdAt --jq \ - '.[] | select(.status == "completed") | [.createdAt, .databaseId] | @tsv' \ - 2> /dev/null || true - done | sort -r | head -n 1 | cut -f 2 - )" - if [ -z "${run}" ] || [ "${run}" = "null" ]; then - echo "No completed revdep4 run found; pass a run id." >&2 - exit 1 - fi - echo "Using newest completed run: ${run}" -fi - -mkdir -p "${dir}" -gh run download "${run}" --name revdepx-report --dir "${dir}" - -# What the run cost, next to what it found: this is the file the next plan -# calibrates on, and having it locally makes a dry run reproducible with -# REVDEPX_MEASURED_DIR="${dir}". -gh run download "${run}" --name revdepx-timings --dir "${dir}" || - echo "Run ${run} published no timings artifact." >&2 - -echo -echo "Results of run ${run} are in ${dir}/:" -ls "${dir}" -echo -if [ -f "${dir}/README.md" ]; then - cat "${dir}/README.md" -fi -echo -echo "To re-check everything that is not ok:" - -echo " gh workflow run revdep4.yaml -f retry-run=${run}" diff --git a/.github/workflows/revdepx/image.R b/.github/workflows/revdepx/image.R deleted file mode 100644 index 6897db6..0000000 --- a/.github/workflows/revdepx/image.R +++ /dev/null @@ -1,754 +0,0 @@ -# Build the dependency universe into the image: install every package the -# plan's checks need -- and the system libraries they require -- into -# /opt/revdepx/lib, load-test the result, and leave behind the index and -# marker files the rest of the workflow trusts. This script runs INSIDE the -# image-build container, as root, and only ever sees an ordinary filesystem: -# the workflow starts the container (from the plain base image, or from the -# previous universe image for a delta build), bind-mounts the pak cache so -# downloads persist across runs, runs this, and commits the container as the -# revdepx-universe image every shard then pulls. -# -# A dependency failure is a report, not a stop: shards screen each revdep -# against the baked index and skip what cannot be checked, and a revdep whose -# dependencies genuinely cannot be installed fails its own check with an -# install log, which is the result a report can work with. depfail.json and -# build-report.md land in OUT_DIR for the workflow to upload. -# -# Environment variables: -# PLAN - plan.json from plan.R (default: plan.json) -# OUT_DIR - where depfail.json and build-report.md land (default: -# universe); bind-mounted by the caller and uploaded as the -# revdepx-universe-report artifact -# REVDEPX_BASE_IMAGE - the base-image tag this build started from, -# recorded in the library index as an opaque string -# REVDEPX_UNIVERSE_OVERRIDE_SHARD - a shard index: install only that -# shard's install list instead of the whole universe. This is -# the local-fallback path in shard-prep.sh -- a shard that can -# procure no image builds one for itself, and pays only for its -# own slice. -# -# Nothing here waits without a clock: REVDEPX_INSTALL_TIMEOUT_MINUTES bounds -# one pak call, REVDEPX_LOAD_TIMEOUT_MINUTES one load-test session, and -# REVDEPX_INSTALL_DEADLINE_MINUTES the installs together -- see the README's -# "Nothing waits for ever". - -script_dir <- dirname(sub( - "--file=", - "", - grep("^--file=", commandArgs(), value = TRUE) -)) -source(file.path(script_dir, "util.R")) - -# A headless container has no X display, and Tk-based packages -# (gWidgets2tcltk and friends) initialise Tk while their code is lazy-loaded -# AT INSTALL TIME: without a display the install dies with -# `[tcl] invalid command name "font"`. CRAN's own check machines run under -# X; ours get a virtual framebuffer. Started here, once, so every child this -# script spawns -- pak installs, load-test sessions -- inherits the display; -# `-ac` is safe because nothing else shares the container's network -# namespace. Dies with the container. -if (!nzchar(Sys.getenv("DISPLAY")) && nzchar(Sys.which("Xvfb"))) { - system2( - "Xvfb", - c(":99", "-screen", "0", "1280x1024x24", "-ac", "-nolisten", "tcp"), - wait = FALSE, - stdout = FALSE, - stderr = FALSE - ) - Sys.setenv(DISPLAY = ":99") - inform("Xvfb started on :99 for Tk-based installs and load tests") -} - -# Before anything talks to a repository: the base image bakes in a p3m.dev -# CRAN snapshot frozen on the day rocker built it. Installing against that -# would quietly resolve last month's versions, while the plan's dependency -# fingerprints -- the baseline reuse key -- are computed from CRAN today; -# the mismatch would fail nothing and check a world the plan did not -# describe. So the first act is to point this process at the rolling -# `latest` binary snapshot for the container's own Ubuntu release. Only this -# process's options, no site Rprofile: pak's children inherit the set -# through pinned_repos() in util.R, which snapshots these options before the -# first install, and the committed image may keep rocker's frozen default -- -# check containers install nothing. -codename <- local({ - lines <- tryCatch( - readLines("/etc/os-release", warn = FALSE), - error = function(e) character() - ) - hit <- grep("^VERSION_CODENAME=", lines, value = TRUE) - gsub('"', "", sub("^VERSION_CODENAME=", "", hit))[1] -}) -if (is.na(codename) || !nzchar(codename)) { - stop( - "/etc/os-release names no VERSION_CODENAME; refusing to install ", - "against the base image's frozen CRAN snapshot", - call. = FALSE - ) -} -options( - repos = c( - CRAN = sprintf("https://p3m.dev/cran/__linux__/%s/latest", codename) - ) -) - -plan <- read_json(env_chr("PLAN", "plan.json")) -out_dir <- env_chr("OUT_DIR", "universe") -dir.create(out_dir, recursive = TRUE, showWarnings = FALSE) -# `plan$package` normally names it. The fallback reads this repository's own -# DESCRIPTION rather than a hardcoded name, so the kit is correct in every -# repository it is broadcast to; the repository name is not a safe source, -# since igraph/rigraph ships the `igraph` package. -package <- plan$package %||% - unname(read.dcf("DESCRIPTION", fields = "Package")[1, 1]) - -# The install set is the whole universe: everything any shard's checks need -# installed anywhere. plan$universe is the sorted union plan.R writes; a -# plan from before the field existed still works, from the union of the -# shards' own install lists. -install_set <- unlist(plan$universe, use.names = FALSE) -if (length(install_set) == 0) { - install_set <- sort(unique(unlist( - lapply(plan$shards, function(s) unlist(s$install, use.names = FALSE)), - use.names = FALSE - ))) -} - -# Under the shard override, only that shard's slice is installed and only -# its own packages' system requirements are surveyed: the fallback runs on a -# shard's clock, and every other shard's dependencies would be minutes spent -# on packages this runner will never check. -override_shard <- env_chr("REVDEPX_UNIVERSE_OVERRIDE_SHARD") -shard_packages <- function(shards) { - sort(unique(unlist( - lapply(shards, function(s) { - vapply(s$packages, function(p) p$name, character(1)) - }), - use.names = FALSE - ))) -} -if (nzchar(override_shard)) { - mine <- Filter( - function(s) identical(as.integer(s$index), as.integer(override_shard)), - plan$shards - ) - if (length(mine) != 1) { - stop( - "REVDEPX_UNIVERSE_OVERRIDE_SHARD=", - override_shard, - " names no shard of the plan", - call. = FALSE - ) - } - install_set <- sort(unique(unlist(mine[[1]]$install, use.names = FALSE))) - checked_packages <- shard_packages(mine) - set_label <- sprintf("shard %s's install union", override_shard) -} else { - checked_packages <- shard_packages(plan$shards) - set_label <- "the plan's dependency universe" -} - -lib <- "/opt/revdepx/lib" -dir.create(lib, recursive = TRUE, showWarnings = FALSE) -# In front of this session's own paths, so everything that asks the session -# rather than being handed `lib` explicitly -- pak resolving what is already -# installed, stray installed.packages() calls -- sees the library being -# built. -.libPaths(c(lib, .libPaths())) -failures <- list() - -# What was in the library before the first chunk: non-empty when the caller -# warm-started the container from the previous universe image (a delta -# build). These play the part the restored tarballs played in the host -# design -- packages that need not be built again while still current, and -# the prime suspects when the load test fails. -preinstalled <- list.dirs(lib, full.names = FALSE, recursive = FALSE) -inform( - "Image: ", - length(preinstalled), - " package(s) already in ", - lib, - " from the donor image" -) - -# With a warm-started library, `upgrade = FALSE` would freeze whatever -# versions the donor image happened to hold; the plan's dependency -# fingerprints are computed from CRAN *now*, so the library has to follow -# CRAN now. The same reasoning governed the old restored-library path -- -# and what "outdated" means is pak's call either way. -upgrade <- length(preinstalled) > 0 - -# This install is the whole build, and the place its ancestor died: handed -# the whole universe at once, pak resolves every one of those refs before it -# installs any of them, and the resolution of a few thousand is where a run -# that is killed rather than failed gets killed. So it goes in dependency -# order, four hundred at a time (see install_chunks() in util.R), which -# keeps every resolution well clear of the size that killed it and turns a -# fatal ten minutes of silence into a chunk counter. -chunk_size <- env_num("REVDEPX_INSTALL_CHUNK", 400) -# Past this, no further chunk is started. The workflow's own timeout on the -# build step cancels everything; this stops earlier and on purpose, so the -# packages that did install are still load-tested and indexed, and the -# container is still committed, instead of dying with the step. -install_deadline <- Sys.time() + - env_num("REVDEPX_INSTALL_DEADLINE_MINUTES", 210) * 60 -# And a deadline for the whole build, because stopping the *installs* early -# only helps if what follows them is bounded too. After `install_deadline` -# come the sysreqs surveys, the load sweep at up to -# REVDEPX_LOAD_TIMEOUT_MINUTES per session, a per-package retry of every -# failure, and a rebuild loop of up to REVDEPX_INSTALL_TIMEOUT_MINUTES per -# stale binary -- whose worst case is far past the step's timeout. Reaching -# that means the container is never committed and no universe image is -# published, so every shard falls back to building its own slice: the one -# outcome this build exists to prevent. -job_deadline <- Sys.time() + - env_num("REVDEPX_JOB_DEADLINE_MINUTES", 270) * 60 -out_of_time <- function(what) { - if (Sys.time() <= job_deadline) { - return(FALSE) - } - inform( - "Past the build deadline; ", - what, - " stops here so the library is still indexed and committed" - ) - TRUE -} -chunks <- install_chunks(install_set, dep_db(), chunk_size) -inform( - "Image: installing ", - length(install_set), - " packages (", - length(missing_from(lib, install_set)), - " not in the library yet) in ", - length(chunks), - " chunk(s) of at most ", - chunk_size, - ", dependencies first; upgrade = ", - upgrade -) -# Before the first install, not after the first failure: a poisoned metadata -# database is inherited through the pak cache the caller bind-mounts, so the -# build can start with one. Asking pak what it can see costs seconds and is -# the difference between one bad build and committing an image that fails -# every shard the same way. -metadata <- ensure_metadata("Image") -if (identical(metadata, "broken")) { - stop( - "pak cannot see the packages that must exist, before or after rebuilding ", - "its metadata database. Installing anything now would fail package by ", - "package for hours and commit an image that fails every shard the same ", - "way.", - call. = FALSE - ) -} - -# What the resource sampler calls the samples it is taking. The sampler runs -# on the host; when the caller bind-mounts the phase file into the container -# this still labels its samples, and when the variable is unset it is a -# no-op. -phase_file <- env_chr("RESOURCE_PHASE_FILE") -phase <- function(name) { - if (nzchar(phase_file)) { - writeLines(name, phase_file) - } - invisible(name) -} - -phase("installing") -install_started <- Sys.time() -installed_ok <- install_in_chunks( - chunks, - lib, - upgrade, - "Image", - deadline = install_deadline -) -inform(sprintf( - "Image: the install %s after %.1f min; %d of %d packages are in the library", - if (installed_ok) "finished" else "failed", - as.numeric(difftime(Sys.time(), install_started, units = "mins")), - length(install_set) - length(missing_from(lib, install_set)), - length(install_set) -)) -if (!installed_ok) { - # One bad package must not hide the state of the other thousand -- but a - # pak transaction is all-or-nothing, so one bad package strands whatever - # shared its chunk, and retrying the stranded one at a time pays pak's - # per-call overhead once per innocent: the resolver runs per call, in - # pak's own private subprocess, and no driver-side cleverness can - # amortise it -- the only lever is the NUMBER of calls. So: divide and - # conquer. Retry the missing set whole; a failing set of more than one - # package is split into three and each third retried, down to single - # packages -- the leaves, where a genuine failure names itself with its - # own log and its own depfail.json line. Subsets without a culprit - # succeed as one call, so d bad packages hiding in n cost about - # 3 * d * log3(n) calls instead of n. Three-way rather than two- or - # four-way because the call count scales with k/ln(k), minimal at k = 3 - # (the group-testing classic; 2 and 4 cost ~6% more, and larger fans - # converge on the flat scan this replaces). The driver risks nothing by - # recursing: it is one long-lived R process whose every pak call already - # runs in its own clocked subprocess, and missing_from() re-measures - # before every call, so whatever a failing transaction did install -- - # pak lands the dependency-ordered prefix before the culprit stops it -- - # is never asked for twice, and a big retry that merely times out - # splits and continues instead of starting over. - install_divide <- function(pkgs, depth = 0L) { - pkgs <- missing_from(lib, pkgs) - if (length(pkgs) == 0) { - return(invisible(NULL)) - } - if (Sys.time() > install_deadline) { - inform(sprintf( - "Image: the install deadline passed; %d package(s) not retried (%s%s)", - length(pkgs), - paste(utils::head(pkgs, 5), collapse = ", "), - if (length(pkgs) > 5) ", ..." else "" - )) - return(invisible(NULL)) - } - run <- pak_install( - pkgs, - lib = lib, - upgrade = upgrade, - timeout_seconds = install_timeout_seconds(), - label = if (length(pkgs) == 1) { - paste("Image: installing", pkgs) - } else { - sprintf("Image retry: %d package(s), depth %d", length(pkgs), depth) - } - ) - if (run$ok) { - return(invisible(NULL)) - } - if (length(pkgs) == 1) { - failures[[length(failures) + 1]] <<- list( - package = pkgs, - phase = "install", - message = run$message - ) - return(invisible(NULL)) - } - size <- ceiling(length(pkgs) / 3) - for (part in split(pkgs, ceiling(seq_along(pkgs) / size))) { - install_divide(part, depth + 1L) - } - invisible(NULL) - } - retry <- missing_from(lib, install_set) - inform( - "Image: isolating ", - length(retry), - " missing package(s) by trisection" - ) - install_divide(retry) -} - -# Before the load test, because a preinstalled package whose system library -# is absent fails to load for a reason that has nothing to do with the -# package: without this it would be judged stale and rebuilt from source, to -# fail again the same way. This container runs as root, so pak's apt calls -# need no sudo -- util.R handles both cases. -phase("surveying system requirements") -ensure_sysreqs(lib, "Image") - -# Load every installed dependency; a failing package is retried on its own -# so a single bad namespace names itself. -installed <- intersect( - install_set, - rownames(utils::installed.packages(lib)) -) -phase("load-testing") -inform("Image: loading ", length(installed), " packages") - -# Bounded, because `loadNamespace()` is not a thing that necessarily -# returns: a package whose .onLoad waits on a lock, a port or a display -# hangs the child for ever, and the ancestor of this script used to wait for -# it with no clock -- the same unbounded wait that cost run 31276552027 its -# preflight, one call further on. A session that runs out of time is a load -# failure like any other, with "timed out" as its reason. -load_timeout_sec <- env_num("REVDEPX_LOAD_TIMEOUT_MINUTES", 10) * 60 -# One session per package, several at a time: loading is mostly I/O and -# dynamic linking, so it parallelises well across the machine's cores. -load_jobs <- max(1, env_num("REVDEPX_LOAD_JOBS", parallel::detectCores())) -load_sweep_sec <- env_num("REVDEPX_LOAD_SWEEP_MINUTES", 60) * 60 -load_batch <- function(pkgs) { - script <- tempfile(fileext = ".R") - writeLines( - c( - sprintf(".libPaths(c(%s, .libPaths()))", deparse(lib)), - "for (p in commandArgs(trailingOnly = TRUE)) {", - " loadNamespace(p)", - " writeLines(paste0('LOADED ', p))", - "}" - ), - script - ) - args <- c("--vanilla", script, pkgs) - if (requireNamespace("processx", quietly = TRUE)) { - # processx runs the command directly rather than through a shell, so the - # arguments need no quoting of their own. - run <- processx::run( - "Rscript", - args, - timeout = load_timeout_sec, - error_on_status = FALSE, - stderr_to_stdout = TRUE - ) - out <- strsplit(run$stdout %||% "", "\n", fixed = TRUE)[[1]] - timed_out <- isTRUE(run$timeout) - } else { - out <- suppressWarnings(system2( - "Rscript", - # Quoted: system2() quotes the command, but not the arguments. - shQuote(args), - stdout = TRUE, - stderr = TRUE - )) - timed_out <- FALSE - } - loaded <- sub("^LOADED ", "", grep("^LOADED ", out, value = TRUE)) - list(failed = setdiff(pkgs, loaded), log = out, timed_out = timed_out) -} -# Which packages actually have to be loaded. -# -# Loading a namespace loads everything it imports, transitively -- so -# loading the packages nothing else in the set depends on covers the whole -# set. In a DAG every other package is reachable from at least one of those -# roots, by following dependents upwards until there are none. For a -# universe of a few thousand packages the roots are a few hundred, so this -# is the same coverage for a fraction of the sessions. -# -# The saving is real but it is not the main point. One session per package -# means one clock per package: a package whose `.onLoad` blocks used to -# spend a batch's whole ten minutes and take 39 innocent packages with it, -# and the batch then had to be re-run package by package to find out which -# one it was. And independent sessions run at once, which is what the other -# cores are for. -load_roots <- function(pkgs) { - db <- dep_db() - known <- intersect(pkgs, rownames(db)) - if (length(known) == 0) { - return(pkgs) - } - # Depends and Imports only, NOT "strong": "strong" includes LinkingTo, - # but loading a dependent never loads its LinkingTo-only dependencies at - # run time -- a header-only package (BH, cpp11) would be counted as - # covered by its dependents while never actually being loaded by anyone. - # With LinkingTo out of the reachability, such packages become roots and - # get their own load test, and the transitive-coverage argument is exact. - deps <- tools::package_dependencies( - known, - db = db, - which = c("Depends", "Imports"), - recursive = TRUE - ) - depended_on <- unique(unlist(deps, use.names = FALSE)) - roots <- setdiff(known, depended_on) - # Anything the database cannot speak for is tested in its own right rather - # than assumed to be covered by something else. - c(roots, setdiff(pkgs, known)) -} - -load_failures <- list() -roots <- load_roots(installed) -inform(sprintf( - "Image: load-testing %d of %d installed package(s) -- the ones nothing else needs, which pull the rest in -- %d at a time, %.0f min each", - length(roots), - length(installed), - load_jobs, - load_timeout_sec / 60 -)) -if (!out_of_time("the load test") && length(roots) > 0) { - list_file <- file.path(tempdir(), "load-roots.txt") - writeLines(roots, list_file) - run <- run_with_timeout( - function(script, args) { - # stdout captured, stderr inherited: the script writes its verdicts to - # both, and the stderr copy is what reaches the build log as the sweep - # runs rather than half an hour later. - system2(script, args, stdout = TRUE, stderr = "") - }, - list( - script = file.path(script_dir, "load-test.sh"), - args = shQuote(c( - list_file, - lib, - format(round(load_timeout_sec), scientific = FALSE), - format(load_jobs) - )) - ), - # The whole sweep, bounded independently of the per-package clocks: with - # `jobs` in parallel the worst case is roughly `roots / jobs` timeouts, - # and this is the backstop for the case where that is still too long. - timeout_seconds = min( - load_sweep_sec, - max(60, as.numeric(difftime(job_deadline, Sys.time(), units = "secs"))) - ), - label = "load test" - ) - out <- if (is.character(run$value)) run$value else character() - for (line in grep("^FAIL ", out, value = TRUE)) { - parts <- strsplit(line, " ", fixed = TRUE)[[1]] - load_failures[[parts[[2]]]] <- if (identical(parts[[3]], "timeout")) { - sprintf("loading timed out after %.0f min", load_timeout_sec / 60) - } else { - "loading failed" - } - } - - # Every package that was tested, and what it cost, folded away. - # - # Without this the log says "load-testing 498 packages" and then nothing - # at all until the report -- and a package that loads *slowly* has nowhere - # to show up, though every check of anything downstream of it pays that - # cost again. 498 lines is a lot to scroll past, so they go in a collapsed - # group and the interesting ones are repeated outside it. - timed <- do.call( - rbind, - lapply( - strsplit(grep("^(OK|FAIL) ", out, value = TRUE), " ", fixed = TRUE), - function(p) { - data.frame( - package = p[[2]], - seconds = suppressWarnings(as.numeric(utils::tail(p, 1))), - ok = identical(p[[1]], "OK") - ) - } - ) - ) - if (!is.null(timed) && nrow(timed) > 0) { - timed <- timed[order(-timed$seconds), ] - print_group( - sprintf("Load test: %d package(s), slowest first", nrow(timed)), - sprintf( - "%6.0fs %-30s %s", - timed$seconds, - timed$package, - ifelse(timed$ok, "", "FAILED") - ) - ) - slow <- utils::head(timed[timed$ok, ], 5) - inform(sprintf( - "Load test: %d ok, %d failed, %s of CPU across %d job(s); slowest: %s", - sum(timed$ok), - sum(!timed$ok), - format_duration(sum(timed$seconds)), - load_jobs, - paste( - sprintf("%s (%.0fs)", slow$package, slow$seconds), - collapse = ", " - ) - )) - } - if (!run$ok) { - inform("The load test did not finish: ", run$message) - } -} - -# What a failure means is worth a second look, so the ones that failed are -# re-run alone with their output kept. There are few of them by construction. -for (p in names(load_failures)) { - if (out_of_time("the load test")) { - break - } - single <- load_batch(p) - if (length(single$failed) == 0) { - load_failures[[p]] <- NULL - next - } - if (!isTRUE(single$timed_out)) { - load_failures[[p]] <- paste( - utils::tail(sanitize_log(single$log), 20), - collapse = "\n" - ) - } -} - -# A preinstalled package that will not load is a stale binary, not a broken -# package: the base image moved under the donor image's build of it -- a new -# base tag means new system libraries. Throw it away, let pak build it from -# source, and judge it on the second attempt. This is the one failure mode a -# delta build introduces, and it is cheap to undo. -stale <- intersect(names(load_failures), preinstalled) -if (length(stale) > 0) { - inform( - "Image: rebuilding ", - length(stale), - " preinstalled package(s) that would not load" - ) - unlink(file.path(lib, stale), recursive = TRUE) - for (p in stale) { - run <- pak_install( - p, - lib = lib, - upgrade = FALSE, - timeout_seconds = install_timeout_seconds(), - label = paste("Image: rebuilding", p) - ) - if (!run$ok) { - inform("Could not reinstall ", p, ": ", run$message) - } - } - for (p in stale) { - retried <- load_batch(p) - if (length(retried$failed) == 0) { - load_failures[[p]] <- NULL - } else { - load_failures[[p]] <- paste( - utils::tail(sanitize_log(retried$log), 20), - collapse = "\n" - ) - } - } -} -for (p in names(load_failures)) { - failures[[length(failures) + 1]] <- list( - package = p, - phase = "load", - message = load_failures[[p]] - ) -} - -write_json(failures, file.path(out_dir, "depfail.json")) - -# ------------------------------------------- what the checks themselves need -- - -# pak installs the system requirements of what *it* installs, and the checked -# packages themselves are never installed: `R CMD check` builds each one from -# its tarball inside a check container, where nothing resolves its -# SystemRequirements field and nothing may run apt. Libra and its -# `SystemRequirements: gsl` is the war story, told in full at -# ensure_check_sysreqs() in util.R. So the survey runs here, over every -# package the plan will check, and the missing apt packages are baked into -# the image -- as root, no sudo, which ensure_check_sysreqs() handles itself. -phase("surveying the checked packages' system requirements") -if (!out_of_time("the check system-requirements survey")) { - ensure_check_sysreqs(checked_packages, "Image") -} - -# ------------------------------------------------------------- the sweep-up -- - -# What the container wrote outside the library must not ride into the image: -# `docker commit` copies every byte of the rw layer, and on the delta path -# (FROM the previous universe image) anything committed once persists in -# every descendant image for as long as the lineage lives. The pak download -# and metadata cache is a host bind mount and never enters the layer; this -# sweeps what does enter it -- apt's package lists from the sysreqs runs -# (their .deb archives are auto-cleaned by the base image's docker-clean -# hook), and the /tmp build trees that killed subprocesses leave behind: a -# pak install that hits REVDEPX_INSTALL_TIMEOUT_MINUTES dies mid-build and -# never removes its extracted sources and objects. When the caller -# bind-mounts /tmp from the host too (the workflows now do), the /tmp part -# is a no-op here and the residue never even counts toward the delta. -phase("sweeping temporary files") -if (nzchar(Sys.which("apt-get"))) { - system2("apt-get", "clean", stdout = FALSE, stderr = FALSE) -} -unlink("/var/lib/apt/lists", recursive = TRUE) -dir.create( - "/var/lib/apt/lists/partial", - recursive = TRUE, - showWarnings = FALSE -) -tmp_junk <- setdiff( - list.files("/tmp", all.files = TRUE, full.names = TRUE, no.. = TRUE), - # This session's own tempdir stays: the report below still uses it. - tempdir() -) -unlink(tmp_junk, recursive = TRUE) - -# ------------------------------------------------------------------ the bake -- - -# The baked library must not hold the package under test at all. Each check -# container mounts a half-specific library in front of it holding exactly -# one copy -- CRAN's release or the dev build -- and a copy here would sit -# *behind* both, shadowing neither: a half whose own install failed would -# quietly check against whatever version the resolver left in the universe, -# the same one on both sides, and the comparison would come back clean and -# meaningless. With the copy gone, that failure is a loud missing-package -# error instead. -unlink(file.path(lib, package), recursive = TRUE) - -phase("indexing the library") -ip <- utils::installed.packages(lib) -index <- list( - r_version = paste( - R.version$major, - sub("[.].*$", "", R.version$minor), - sep = "." - ), - r_full_version = paste(R.version$major, R.version$minor, sep = "."), - platform = R.version$platform, - base_image = env_chr("REVDEPX_BASE_IMAGE"), - built_at = now_utc(), - count = nrow(ip), - packages = unname(Map( - function(p, v) list(package = p, version = v), - rownames(ip), - unname(ip[, "Version"]) - )) -) -# The index is the library's passport: shard-prep.sh extracts it on every -# shard, and the depfail screen there reads it instead of walking the -# library. -write_json(index, "/opt/revdepx/lib-index.json") -# And the marker is the one-file answer to "did the build run to its end" -- -# shard-prep.sh warns about images that lack it. -writeLines(now_utc(), "/opt/revdepx/universe-ok") - -report <- c( - "## revdepx universe image", - "", - sprintf( - "The baked library holds %d package(s) after installing %s (%d planned; %d failed to install or load).", - index$count, - set_label, - length(install_set), - length(failures) - ), - "", - sprintf( - "%d package(s) were already in the library from the donor image%s; `%s` is evicted -- each check mounts its own copy in front.", - length(preinstalled), - if (length(stale) > 0) { - sprintf(" (%d rebuilt after failing to load)", length(stale)) - } else { - "" - }, - package - ), - "", - sprintf( - "R %s on %s, base image `%s`.", - index$r_full_version, - index$platform, - if (nzchar(index$base_image)) index$base_image else "unrecorded" - ), - "" -) -if (length(failures) > 0) { - df <- data.frame( - Package = vapply(failures, function(f) f$package, character(1)), - Phase = vapply(failures, function(f) f$phase, character(1)) - ) - report <- c(report, md_table(df), "") - for (f in failures) { - report <- c( - report, - md_details( - sprintf("%s — %s failure", f$package, f$phase), - strsplit(f$message, "\n")[[1]] - ) - ) - } - inform( - length(failures), - " dependencies failed the image build; see depfail.json" - ) -} -writeLines(report, file.path(out_dir, "build-report.md")) -# GITHUB_STEP_SUMMARY does not exist inside the build container, and -# append_summary() then falls back to plain output (see util.R) -- so this -# lands in the container log, and build-report.md above is the copy the -# workflow uploads. -append_summary(report) diff --git a/.github/workflows/revdepx/load-test.sh b/.github/workflows/revdepx/load-test.sh deleted file mode 100755 index 0a1b63d..0000000 --- a/.github/workflows/revdepx/load-test.sh +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env bash -# Load every named package in its own R session, several at a time, each on a -# clock. -# -# Usage: -# load-test.sh -# -# Reads one package name per line. Prints one line per package on stdout: -# -# OK -# FAIL -# -# and the same verdict on stderr as it happens, with a running count: -# -# [load 123/1173] OK red 19s -# -# The two streams are separate on purpose. stdout is the caller's data and is -# captured; stderr is the live log, so a sweep that takes half an hour says -# what it is doing while it does it instead of only afterwards. The caller -# still folds the sorted summary into a collapsed group at the end -- that is -# the one that answers "what was slow", which the arrival order cannot. -# -# Always exits 0: which packages failed is the caller's business, not the -# shell's. -# -# The `OK` lines are the whole log of a step that otherwise says nothing -# between "load-testing 498 packages" and the summary. They are also the only -# place a package that loads *slowly* -- half a minute of `.onLoad`, every -# time anything downstream of it is checked -- ever shows up. The caller folds -# them into a collapsed group, so the cost of the other 497 is a line nobody -# has to scroll past. -# -# Why one session per package rather than batches: -# -# * a batch shares one clock, so one package that hangs spends the whole -# budget and takes 39 innocent packages down with it, and the caller then -# has to re-run each of them alone to find out which. Per package, the -# answer is immediate and the blast radius is one. -# * sessions are independent, so they run at once. A runner has four cores -# and `loadNamespace()` is mostly I/O and dynamic linking, so the wall -# clock falls by about the number of jobs. -# * `timeout` sends TERM at the deadline and KILL a minute later, to the -# process group, so a package whose `.onLoad` blocks on a socket is -# actually killed rather than merely abandoned. - -set -u - -list=$1 -lib=$2 -seconds=$3 -jobs=$4 - -total=$(grep -c . "${list}" || true) -width=${#total} - -# The running count, without a lock. Every finished package appends one byte -# and reads the size back; single-byte appends to an O_APPEND descriptor do not -# interleave, so the number is exact rather than approximately right. A stale -# count would be cosmetic either way -- it is a progress indicator, not data. -progress=$(mktemp) -trap 'rm -f "${progress}"' EXIT - -# One package, one session, one clock. `--vanilla` so nothing in a profile -# loads anything this is supposed to be testing. -load_one() { - local pkg=$1 status=0 start=${EPOCHSECONDS} - timeout --kill-after=60s "${seconds}s" \ - Rscript --vanilla -e \ - ".libPaths(c('${lib}', .libPaths())); loadNamespace('${pkg}')" \ - > /dev/null 2>&1 || status=$? - local took=$((EPOCHSECONDS - start)) verdict - if [ "${status}" -eq 0 ]; then - verdict=OK - echo "OK ${pkg} ${took}" - # 124 is coreutils' timeout; anything else is R saying something. - elif [ "${status}" -eq 124 ] || [ "${status}" -eq 137 ]; then - verdict=TIMEOUT - echo "FAIL ${pkg} timeout ${took}" - else - verdict=ERROR - echo "FAIL ${pkg} error ${took}" - fi - printf '.' >> "${progress}" - printf '[load %*d/%d] %-7s %-32s %ss\n' \ - "${width}" "$(wc -c < "${progress}")" "${total}" \ - "${verdict}" "${pkg}" "${took}" >&2 -} -export -f load_one -export lib seconds progress total width - -# GNU parallel where it exists, `xargs -P` where it does not -- the runners -# have both, but a local invocation may not, and the two are interchangeable -# for this. -if command -v parallel > /dev/null 2>&1; then - parallel --will-cite -j "${jobs}" load_one :::: "${list}" -else - xargs -a "${list}" -r -n 1 -P "${jobs}" -I '{}' \ - bash -c 'load_one "$@"' _ '{}' -fi - -exit 0 diff --git a/.github/workflows/revdepx/plan.R b/.github/workflows/revdepx/plan.R deleted file mode 100644 index bb519d8..0000000 --- a/.github/workflows/revdepx/plan.R +++ /dev/null @@ -1,1435 +0,0 @@ -# Plan the sharded reverse-dependency check, for both revdepx engines. -# -# Enumerates the reverse dependencies of the package in the current directory, -# weighs each one by what its check is expected to cost on these runners, -# decides which stored old-version results may stand beside this run's fresh -# checks as second opinions, and partitions the packages into cost-balanced -# shards. One shard becomes one matrix leg of revdep4.yaml. -# -# The bill the plan prices: a package's two halves run sequentially, so the -# package costs both of them end to end, and REVDEPX_WORKERS packages run at -# once, so a shard's wall clock is its check load divided by the workers. -# -# Both halves are always checked fresh. A stored old result from an -# earlier run (the baseline) rides along as a *second opinion* -- the shard -# records whether the fresh old check reproduced it (`baseline_agrees`) -- -# and never substitutes for the check itself. -# -# The partitioning is greedy, in two phases (see revdep2/README.md for why -# greedy beats an exact formulation here): -# -# 1. The K heaviest packages are dealt round-robin, one per shard, so no two -# giants share a leg. -# 2. Every remaining package, heaviest first, goes to the shard where its -# marginal cost is smallest: its own wall-clock contribution plus an -# install penalty for each dependency the shard does not already need. -# With the dependency universe baked into a shared image the penalty -# defaults to zero and the deal is pure load balancing; the knob stays -# for the fallback path, where a shard builds its own library after all. -# -# K is bounded by the parallel capacity, not by the budget alone. Only -# `max-parallel` shards ever run at once, so shard K+1 of a full wave does not -# start any earlier for having been split off -- it just pays another setup. -# The rule is therefore: as many shards as the budget wants while they all fit -# in one wave, and beyond that, whole waves -- as many as the per-shard -# capacity demands, and no more. -# -# The cost model behind all of it -- how fast a check runs here, what a shard -# costs before it checks anything, what one more dependency costs to install -- -# is calibrated from the timings artifact of the last runs of both workflows, -# and falls back to CRAN's numbers and the defaults below when no run has -# measured anything yet. -# -# Environment variables (inputs): -# REVDEPX_PACKAGES - explicit packages to check (comma/space separated; -# default: all reverse dependencies), or the word -# `broken` to take them from the committed report -# REVDEPX_WHICH - "strong" (default) or "most" (adds Suggests/ -# Enhances dependents) -# REVDEPX_WORKERS - packages checked concurrently per shard -# (default: 4) -# REVDEPX_R_VERSION - the full R version the *containers* check under, -# e.g. "4.5.3", resolved by the workflow; required, -# because the baseline key must name the R that ran -# the checks, not the R running this script -# REVDEPX_WORKFLOWS - workflow files whose completed runs the history -# walk mines for baselines and timings (default: -# "revdep4.yaml") -# REVDEPX_RETRY_RUN - run id of an earlier revdepx run; check -# only the packages that run could not declare ok -# REVDEPX_PART - "i/G": check one G-th of the batch, for a revdep -# set too big for a single run (the plan refuses -# such a batch and prints the G it needs) -# REVDEPX_RECHECK_REPORT - if truthy, check what the committed report lists -# as broken or failed (same as REVDEPX_PACKAGES=broken) -# REVDEPX_REPORT_DIR - where that report lives (default: revdep) -# REVDEPX_SHARD_BUDGET_MINUTES - check-time target per shard (default: 45) -# REVDEPX_SHARD_CAPACITY_MINUTES - check minutes one shard may be given at -# most, which is what forces a second wave -# (default: 80% of REVDEPX_DEADLINE_MINUTES) -# REVDEPX_LONG_RUN_HOURS - estimated wall clock past which the plan warns in -# the job summary; it never refuses for length -# alone (default: 12) -# REVDEPX_MAX_SHARDS - matrix legs to emit at most (default: 250) -# REVDEPX_MAX_PARALLEL - legs to run concurrently, and so the size of one -# wave (default: 20) -# REVDEPX_REFRESH_BASELINE- if truthy, offer no stored old results as second -# opinions (the old half runs fresh either way) -# REVDEPX_BASELINE_MAX_AGE_DAYS - oldest baseline worth reusing (default: 30) -# REVDEPX_HISTORY_RUNS - earlier runs the donor walk looks at at all -# (default: 40) -# REVDEPX_MEASURED_MAX_RUNS - earlier runs whose measured timings calibrate -# the cost model (default: 3; 0 disables) -# REVDEPX_MEASURED_MAX_AGE_DAYS - oldest measurement worth trusting -# (default: 60) -# REVDEPX_MEASURED_DIR - offline hook: a directory holding a timings.json, -# used instead of walking the run history -# REVDEPX_CHECK_SCALE - check seconds here per second CRAN reports; -# overrides the measured value (default: measured, -# else 1) -# REVDEPX_SETUP_MINUTES - fixed cost of one shard before it checks anything, -# image pull included; overrides the measured value -# (default: measured, else 10) -# REVDEPX_INSTALL_SECONDS - marginal install cost charged per dependency a -# package adds to its shard; only the image-less -# fallback path installs anything, so this prices -# nothing normally (default: measured from -# same-engine runs, else 0) -# REVDEPX_TIMINGS_FILE - offline hook: RDS or CSV with columns Package and -# T_total, used instead of tools::CRAN_check_results() -# OUT - plan file to write (default: plan.json) -# -# Also reads GITHUB_REPOSITORY / GITHUB_SHA / GITHUB_REF_NAME and, for baseline -# discovery, uses the `gh` CLI with GH_TOKEN. Without gh or a token the plan -# simply reuses nothing. - -source(file.path( - dirname(sub("--file=", "", grep("^--file=", commandArgs(), value = TRUE))), - "util.R" -)) - -out_path <- env_chr("OUT", "plan.json") -which_input <- match.arg( - env_chr("REVDEPX_WHICH", "strong"), - c("strong", "most") -) -depth_raw <- tolower(env_chr("REVDEPX_DEPTH", "1")) -depth <- if (depth_raw %in% c("all", "max", "inf", "infinity")) { - Inf -} else { - suppressWarnings(as.numeric(depth_raw)) -} -if (is.na(depth) || depth < 1) { - depth <- 1 -} -budget <- env_num("REVDEPX_SHARD_BUDGET_MINUTES", 45) -max_shards <- min(env_num("REVDEPX_MAX_SHARDS", 250), 250) -max_parallel <- env_num("REVDEPX_MAX_PARALLEL", 20) -# A shard stops starting checks at its own deadline and defers the rest, so the -# deadline is what actually caps a shard's check load; the plan aims below it, -# leaving the rest of the job for installing and for the checks running long. -deadline_minutes <- env_num("REVDEPX_DEADLINE_MINUTES", 300) -capacity <- env_num("REVDEPX_SHARD_CAPACITY_MINUTES", 0.8 * deadline_minutes) -refresh_baseline <- env_flag("REVDEPX_REFRESH_BASELINE") -baseline_max_age <- env_num("REVDEPX_BASELINE_MAX_AGE_DAYS", 30) -history_runs <- env_num("REVDEPX_HISTORY_RUNS", 40) -max_measured_runs <- env_num("REVDEPX_MEASURED_MAX_RUNS", 3) -measured_max_age <- env_num("REVDEPX_MEASURED_MAX_AGE_DAYS", 60) -recheck_report <- env_flag("REVDEPX_RECHECK_REPORT") -report_dir <- env_chr("REVDEPX_REPORT_DIR", "revdep") -overhead_minutes <- env_num("REVDEPX_PACKAGE_OVERHEAD_MINUTES", 0.5) -retry_run <- env_chr("REVDEPX_RETRY_RUN") -repo <- env_chr("GITHUB_REPOSITORY") -# The CRAN flavor whose reported check times seed the cost model. CRAN runs no -# oldrel Linux flavor, so even when the containers check under oldrel the -# release flavor is the closest Linux number there is -- check_scale absorbs -# the difference like any other speed gap. -timing_flavor <- env_chr("REVDEPX_TIMING_FLAVOR", "r-release-linux-x86_64") -# The queue engine is the only one -- the pair engine (revdep3) was retired -# with its unmerged PR -- and the constant keeps the engine-tagged plan, -# manifest and timings schema unchanged. -engine <- "queue" -workers <- max(1, env_num("REVDEPX_WORKERS", 4)) -# How many packages a shard works on at once: one per worker. This is the -# divisor that turns a shard's check load into wall clock. -parallelism <- workers -workflow_files <- strsplit( - env_chr("REVDEPX_WORKFLOWS", "revdep4.yaml"), - "[,[:space:]]+" -)[[1]] -workflow_files <- workflow_files[nzchar(workflow_files)] -# Which workflow file dispatched *this* run, for messages that print a -# re-dispatch command. GITHUB_WORKFLOW_REF looks like -# "owner/repo/.github/workflows/revdep4.yaml@refs/heads/main". -this_workflow_file <- local({ - ref <- env_chr("GITHUB_WORKFLOW_REF") - file <- basename(sub("@.*$", "", ref)) - if (nzchar(file) && grepl("[.]ya?ml$", file)) file else "revdep4.yaml" -}) - -# The R the *checks* run under -- the container's, resolved by the workflow -- -# not the R running this script. Everything keyed on an R version (the -# baseline verdict, the plan record, the image tags) means this one. -r_full_version <- env_chr("REVDEPX_R_VERSION") -if (!nzchar(r_full_version)) { - stop( - "REVDEPX_R_VERSION must be set to the container R version ", - "(the workflow resolves it before planning)", - call. = FALSE - ) -} -r_version <- paste( - strsplit(r_full_version, ".", fixed = TRUE)[[1]][1:2], - collapse = "." -) - -# The base image tag pins the container platform under the checks: rocker -# r-ver at the resolved R version plus this tree's toolchain layer. It is -# derived from the sibling script alone -- the same recipe base-image.sh -# hashes -- so the plan can name it without docker and without waiting for the -# base job. A baseline row records it, and only a run standing on the same -# tag may reuse that row: a different base image means different system -# libraries under the same package versions. -base_image_tag <- local({ - script <- file.path( - dirname(sub("--file=", "", grep("^--file=", commandArgs(), value = TRUE))), - "base-image.sh" - ) - hash <- unname(tools::md5sum(script)) - if (is.na(hash)) { - stop("Cannot hash ", script, " for the base image tag", call. = FALSE) - } - sprintf("r-%s-%s", r_full_version, substr(hash, 1, 12)) -}) - -# ------------------------------------------------------------ empty plans ---- - -plan_nothing <- function(reason) { - inform("Planning nothing: ", reason) - set_output("matrix", '{"shard":["none"]}') - set_output("shards", "0") - set_output("packages", "0") - set_output("max_parallel", "1") - set_output("baseline_run", "0") - set_output("plan_hash", "none") - set_output("universe_count", "0") - append_summary(c("## revdepx plan", "", paste0("Nothing to check: ", reason))) - quit(save = "no", status = 0) -} - -# ------------------------------------------------------------ run history ---- - -# The gh plumbing itself lives in util.R, because the shards fetch artifacts -# too; what is planned here is *which* earlier runs to take them from. -# -# One walk over the completed runs of every workflow in REVDEPX_WORKFLOWS -- -# runs of the retired pair engine published the same artifacts under the same -# names, so old history still serves -- youngest first across all of them, -# answers every question this plan asks of its history, and asks the API for -# a run's artifacts at most once: -# -# * which run donates the CRAN baseline -- the newest one that still has it; -# * which runs donate measured timings -- the youngest few, whose numbers -# calibrate the cost model below. -# -# (revdep2 also hunted prebuilt library donors here. The universe image made -# that a registry pull, so the walk no longer carries it.) -# -# The walk stops as soon as it has all of them, and never looks at more than -# `history_runs` runs; reuse is an optimization, and an optimization does not -# get to spend the planning budget. -scan_history <- function(want_baseline, want_timings) { - empty <- list( - baseline_run = NULL, - timings = list(), - timings_runs = character(), - scanned = 0L - ) - if (!gh_ok() || !nzchar(repo)) { - return(empty) - } - rows <- character() - for (workflow in workflow_files) { - got <- gh_lines( - "api", - sprintf( - "repos/%s/actions/workflows/%s/runs?status=completed&per_page=%d", - repo, - workflow, - history_runs - ), - "--jq", - ".workflow_runs[] | [.id, .created_at] | @tsv" - ) - # A workflow file that does not exist here -- only one of the two PRs - # merged, or a fork carries one engine -- is an empty contribution, not an - # error; gh_lines() already returned NULL for it. - rows <- c(rows, got) - } - rows <- rows[nzchar(rows)] - if (length(rows) == 0) { - return(empty) - } - # Youngest first across both workflows: the per-workflow pages each come - # newest first, but the merge does not, and "the newest baseline" must mean - # the newest of either engine. - created_at <- vapply( - rows, - function(row) strsplit(row, "\t", fixed = TRUE)[[1]][[2]], - character(1), - USE.NAMES = FALSE - ) - rows <- rows[order(created_at, decreasing = TRUE)] - this_run <- env_chr("GITHUB_RUN_ID") - baseline_run <- NULL - timings <- list() - timings_runs <- character() - scanned <- 0L - for (row in rows) { - if (!want_baseline && length(timings) >= want_timings) { - break - } - fields <- strsplit(row, "\t", fixed = TRUE)[[1]] - run <- fields[[1]] - if (identical(run, this_run)) { - next - } - created <- suppressWarnings(as.Date(fields[[2]])) - scanned <- scanned + 1L - ids <- run_artifacts(run) - artifacts <- names(ids) - if (want_baseline && "revdepx-baseline" %in% artifacts) { - baseline_run <- run - want_baseline <- FALSE - } - # Timings age the way baselines do: a runner image moves, and with it what - # a check costs. They are tiny, so taking the youngest few and pooling them - # is cheaper than trusting a single run that may have been a small retry. - take_timings <- length(timings) < want_timings && - "revdepx-timings" %in% artifacts && - !is.na(created) && - as.numeric(Sys.Date() - created) <= measured_max_age - if (take_timings) { - dir <- fetch_artifact_id(ids[["revdepx-timings"]], tempfile("timings-")) - measured <- read_timings(dir) - unlink(dir, recursive = TRUE) - if ( - !is.null(measured) && - identical(measured$platform, R.version$platform) - ) { - timings[[length(timings) + 1]] <- measured - timings_runs <- c(timings_runs, run) - } - } - } - list( - baseline_run = baseline_run, - timings = timings, - timings_runs = timings_runs, - scanned = scanned - ) -} - -# ------------------------------------------------- the package under test ---- - -desc <- read.dcf("DESCRIPTION")[1, ] -package <- unname(desc[["Package"]]) -dev_version <- unname(desc[["Version"]]) -inform("Package under test: ", package, " ", dev_version) - -db <- cran_db() -if (!package %in% rownames(db)) { - plan_nothing(sprintf( - "%s is not on CRAN, so it has no CRAN reverse dependencies", - package - )) -} -cran_version <- unname(db[package, "Version"]) -inform("CRAN version: ", cran_version) - -# ------------------------------------------------------------- enumeration --- - -# Breadth-first over reverse dependencies: level 1 depends on the package -# directly, level 2 on a level-1 package, and so on. Deeper levels break -# through their intermediaries, so checking them still compares CRAN vs dev -# meaningfully. The walk stops at `depth`, or at the fixpoint for "all". -level_of <- integer() -frontier <- package -level <- 0L -while (level < depth && length(frontier) > 0) { - found <- tools::package_dependencies( - frontier, - db = db, - which = if (which_input == "most") "most" else "strong", - reverse = TRUE - ) - fresh <- setdiff( - unique(unlist(found, use.names = FALSE)), - c(names(level_of), package) - ) - level <- level + 1L - level_of[fresh] <- level - frontier <- fresh -} -revdeps <- sort(names(level_of)) -level_counts <- table(level_of) -inform( - length(revdeps), - " reverse dependencies (", - which_input, - ", depth ", - depth_raw, - if (length(level_counts) > 1) { - paste0( - "; ", - paste0("level ", names(level_counts), ": ", level_counts, collapse = ", ") - ) - } else { - "" - }, - ")" -) - -# `selection` goes into plan.json, so it stays plain; `selection_md` is the -# same thing with the run id clickable, for the job summary. -selection <- "all" -selection_md <- NULL -retry_manifest <- NULL -packages_input <- trimws(strsplit( - env_chr("REVDEPX_PACKAGES"), - "[,[:space:]]+" -)[[1]]) -packages_input <- packages_input[nzchar(packages_input)] - -# `packages: broken` is a selector, not a package name: the dispatch form has -# room for few inputs, and "what was wrong last time" belongs with "what to -# check" rather than beside it. -if ( - length(packages_input) == 1 && - tolower(packages_input) %in% c("broken", "failed", "report") -) { - recheck_report <- TRUE - packages_input <- character() -} - -if (length(packages_input) > 0) { - selection <- "explicit" - candidates <- unique(packages_input) -} else if (recheck_report) { - # The committed report is the durable record of what was wrong last time: - # every package it lists as a problem or a failure, re-checked. This is the - # `revdep/run-broken.R` loop that predates this workflow, as an input. - found <- report_packages(report_dir) - if (length(found$packages) == 0) { - plan_nothing(sprintf( - "%s lists no broken or failed packages (looked for manifest.json, problems.md, failures.md, README.md)", - report_dir - )) - } - selection <- sprintf("broken and failed in %s", report_dir) - candidates <- found$packages - inform( - "Re-checking ", - length(candidates), - " package(s) from ", - report_dir, - " (", - found$source, - ")" - ) -} else if (nzchar(retry_run)) { - selection <- sprintf("retry of run %s", retry_run) - selection_md <- sprintf("retry of run %s", run_link(retry_run)) - dir <- fetch_artifact(retry_run, "revdepx-report", tempfile("retry-")) - manifest_path <- if (is.null(dir)) NULL else file.path(dir, "manifest.json") - if (is.null(manifest_path) || !file.exists(manifest_path)) { - stop( - "Cannot fetch the revdepx-report artifact of run ", - retry_run, - call. = FALSE - ) - } - retry_manifest <- read_json(manifest_path) - results <- vapply(retry_manifest, function(e) e$result, character(1)) - candidates <- vapply(retry_manifest, function(e) e$package, character(1))[ - vapply(results, needs_recheck, logical(1)) - ] - inform( - "Retrying ", - length(candidates), - " of ", - length(retry_manifest), - " packages from run ", - retry_run - ) -} else { - candidates <- revdeps -} - -dropped <- setdiff(candidates, rownames(db)) -if (length(dropped) > 0) { - inform("Not on CRAN, dropped: ", paste(dropped, collapse = ", ")) -} -packages <- intersect(candidates, rownames(db)) -if (length(packages) == 0) { - plan_nothing("no packages left to check") -} -their_version <- setNames(unname(db[packages, "Version"]), packages) - -# ------------------------------------------------------------------ weights -- - -timings_file <- env_chr("REVDEPX_TIMINGS_FILE") -if (nzchar(timings_file)) { - inform("Reading check timings from ", timings_file) - timings <- if (grepl("[.]rds$", timings_file)) { - readRDS(timings_file) - } else { - utils::read.csv(timings_file) - } -} else { - inform("Fetching CRAN check timings (flavor ", timing_flavor, ")") - timings <- tools::CRAN_check_results() - timings <- timings[timings$Flavor == timing_flavor, c("Package", "T_total")] -} -t_total <- setNames( - as.numeric(timings$T_total)[match(packages, timings$Package)], - packages -) -known <- !is.na(t_total) -fallback <- if (any(known)) stats::median(t_total[known]) else 300 -t_total[!known] <- fallback -t_total <- pmax(t_total, 60) - -# ---------------------------------------------------------------- parts ----- - -# `part: i/G` takes one G-th of the batch, for a revdep set too big to check in -# one run (see the refusal in the partitioning section, which computes G and -# prints the dispatch lines). The cut is made here, on CRAN's times, because -# everything downstream -- closures, the dependency universe, the image -# lookup -- should see only the packages this run will check. -# -# Dealing the weight-ordered list round robin keeps the parts of similar size -# without any coordination between the runs: each one re-derives the same -# order from the same CRAN metadata. A package that moves between dispatches -# can land in another part or in none; `retry-run` on the union is the sweep -# for that, and nothing here depends on the parts being exact. -part_input <- trimws(env_chr("REVDEPX_PART")) -part <- NULL -if (nzchar(part_input)) { - fields <- suppressWarnings(as.integer(strsplit(part_input, "/")[[1]])) - if ( - length(fields) != 2 || - anyNA(fields) || - fields[[1]] < 1 || - fields[[2]] < 1 || - fields[[1]] > fields[[2]] - ) { - stop( - "REVDEPX_PART must be `i/G` with 1 <= i <= G, not ", - part_input, - call. = FALSE - ) - } - part <- list(index = fields[[1]], of = fields[[2]]) - # Not `seq(index, n, by = of)`: seq() errors outright ("wrong sign in - # 'by'") when fewer packages remain than the part index -- `part: 4/4` - # over a 3-package explicit list must reach the empty-part plan_nothing() - # below, not die here. - positions <- seq_along(packages) - positions <- positions[positions %% part$of == part$index %% part$of] - mine <- order(-t_total)[positions] - packages <- sort(packages[mine]) - t_total <- t_total[packages] - known <- known[packages] - their_version <- their_version[packages] - suffix <- sprintf(", part %d of %d", part$index, part$of) - selection <- paste0(selection, suffix) - if (!is.null(selection_md)) { - selection_md <- paste0(selection_md, suffix) - } - inform( - "Part ", - part$index, - " of ", - part$of, - ": ", - length(packages), - " packages, ~", - round(sum(t_total) / 60), - " CRAN check minutes" - ) - if (length(packages) == 0) { - plan_nothing(sprintf("part %d of %d is empty", part$index, part$of)) - } -} -inform( - sum(known), - " of ", - length(packages), - " check times known from CRAN; ", - "median fallback ", - round(fallback), - "s for the rest" -) - -# --------------------------------------------------------------- closures ---- - -inform("Computing dependency closures") -# Closures resolve against CRAN *and* Bioconductor: `db` decides what gets -# checked (CRAN reverse dependencies), `deps_db` what those checks need -# installed -- a CRAN package may depend on Bioconductor freely. -deps_db <- dep_db() -closure <- install_closure(packages, deps_db) -fingerprint <- vapply( - packages, - function(p) dep_fingerprint(closure[[p]], deps_db), - character(1) -) - -# The dev version's own dependencies: every shard installs the dev binary, so -# every shard needs them even when no revdep pulls them in. Parsed from the -# checkout's DESCRIPTION, resolved against the dependency metadata. -parse_dep_field <- function(field) { - value <- desc[field] - if (is.na(value)) { - return(character()) - } - entries <- strsplit(value, ",")[[1]] - names <- trimws(sub("[([].*$", "", entries)) - names[nzchar(names) & names != "R"] -} -dev_deps <- unique(unlist(lapply( - c("Depends", "Imports", "LinkingTo"), - parse_dep_field -))) -dev_deps <- intersect(dev_deps, rownames(deps_db)) -dev_closure <- sort(setdiff( - unique(c( - dev_deps, - unlist( - tools::package_dependencies( - dev_deps, - db = deps_db, - which = "strong", - recursive = TRUE - ), - use.names = FALSE - ) - )), - base_packages() -)) - -# Everything this run installs anywhere: the union of the revdeps' closures -# and the dev version's own dependencies. It prices the partitioning penalty -# below, and it is the list the universe image is built from -# against. -universe <- unique(c(unlist(closure, use.names = FALSE), dev_closure)) - -# ------------------------------------------------------------ earlier runs --- - -local_baseline <- env_chr("REVDEPX_BASELINE_DIR") -local_measured <- env_chr("REVDEPX_MEASURED_DIR") -history <- scan_history( - # A retried run donates its own baseline, and the offline hooks bypass - # discovery entirely; whatever is supplied that way, the walk stops looking - # for. - want_baseline = !refresh_baseline && - !nzchar(local_baseline) && - !nzchar(retry_run), - want_timings = if (nzchar(local_measured)) 0 else max_measured_runs -) - -# ---------------------------------------------------------------- baseline --- - -baseline_run <- "0" -baseline_manifest <- list() -if (refresh_baseline) { - inform("Baseline reuse disabled by input") -} else if (nzchar(local_baseline)) { - # Offline hook for testing the eligibility rules without a GitHub run: a - # directory holding baseline.json, e.g. a downloaded revdepx-baseline - # artifact. The shard reads the same directory through BASELINE_DIR. - manifest_path <- file.path(local_baseline, "baseline.json") - if (file.exists(manifest_path)) { - entries <- read_json(manifest_path) - baseline_manifest <- setNames( - entries, - vapply(entries, function(e) e$package, character(1)) - ) - inform( - "Baseline from ", - local_baseline, - " (", - length(baseline_manifest), - " entries)" - ) - } -} else { - donor <- if (nzchar(retry_run)) retry_run else history$baseline_run - if (is.null(donor) || !nzchar(donor)) { - inform("No earlier run with a baseline artifact found") - } else { - dir <- fetch_artifact(donor, "revdepx-baseline", tempfile("baseline-")) - manifest_path <- if (is.null(dir)) NULL else file.path(dir, "baseline.json") - if (is.null(manifest_path) || !file.exists(manifest_path)) { - inform( - "Baseline artifact of run ", - donor, - " is unavailable; reusing nothing" - ) - } else { - baseline_run <- run_id_chr(donor) - entries <- read_json(manifest_path) - baseline_manifest <- setNames( - entries, - vapply(entries, function(e) e$package, character(1)) - ) - inform( - "Baseline donor: run ", - donor, - " (", - length(baseline_manifest), - " entries)" - ) - } - } -} - -# Offer a stored old-version verdict as a second opinion only when everything -# that shaped it is unchanged: the revdep's version, the CRAN version of the -# package under test, the R series, the base image the checks stood on, and -# the resolved versions of the whole install closure -- plus an age cap as -# the backstop for what metadata cannot see (the universe image accumulates -# deltas between full rebuilds). The old half runs fresh regardless; a row -# that fails these conditions is not wrong, it is merely not comparable, and -# a drift verdict against an incomparable row would be noise. -# -# The base-image condition is also the firewall against revdep2-era baselines: -# those rows were measured on the runner's own R and toolchain, carry no -# `base_image`, and two parsers and two machines apart they produced 8.4% -# false newly-broken back when they were allowed to stand in for the old -# half. Rows from either revdepx workflow name the same tag when nothing -# changed -- which is exactly when a disagreement means drift and not noise. -baseline_verdict <- function(p) { - e <- baseline_manifest[[p]] - if (is.null(e)) { - return("none") - } - if (!identical(e$version, unname(their_version[[p]]))) { - return("their-version") - } - if (!identical(e$our_cran_version, cran_version)) { - return("our-version") - } - if (!identical(e$r_version, r_version)) { - return("r-version") - } - if (!identical(e$base_image, base_image_tag)) { - return("base-image") - } - if (!identical(e$dep_fingerprint, unname(fingerprint[[p]]))) { - return("dependencies") - } - checked <- suppressWarnings(as.Date(e$checked_at)) - if (is.na(checked) || as.numeric(Sys.Date() - checked) > baseline_max_age) { - return("age") - } - if (!isTRUE(e$has_old)) { - return("missing-rds") - } - "reuse" -} -verdicts <- vapply(packages, baseline_verdict, character(1)) -reuse <- verdicts == "reuse" -if (has_run(baseline_run)) { - stale <- table(verdicts[!reuse]) - inform( - "Baseline: ", - sum(reuse), - " with a second opinion, ", - sum(!reuse), - " without", - if (length(stale) > 0) { - paste0(" (", paste(names(stale), stale, sep = ": ", collapse = ", "), ")") - } else { - "" - } - ) -} - -# ------------------------------------------------------------- calibration --- - -# CRAN's `T_total` ranks packages well and predicts minutes here badly: it -# comes from a different machine under a different load, and it is the only -# number available for a package this workflow has never checked. So the last -# runs' own measurements come first, and CRAN's number is scaled by what those -# runs say the ratio between the two is. -# -# Every constant is overridable by hand, and every fallback is the value that -# was hard-coded before anything measured itself. -measured_runs <- if (nzchar(local_measured)) { - # Offline hook for reading a downloaded revdepx-timings artifact, the way - # REVDEPX_BASELINE_DIR reads a downloaded baseline. - Filter(Negate(is.null), list(read_timings(local_measured))) -} else { - history$timings -} -cal <- calibration(measured_runs, engine) -measured_seconds <- measured_check_seconds(measured_runs) - -check_scale <- env_num_opt("REVDEPX_CHECK_SCALE") %||% cal$check_scale %||% 1 -# The fixed cost of a shard now includes pulling the universe image, so the -# uncalibrated default sits above revdep2's 6. -setup_minutes <- env_num_opt("REVDEPX_SETUP_MINUTES") %||% - cal$setup_minutes %||% - 10 -# Zero, because a shard's dependencies arrive inside the image it pulls; the -# install penalty then prices nothing and the deal is pure load balancing. -# The knob stays for the fallback world where shards build their own library -# (and calibration reports what fallback shards actually measured). -install_seconds <- env_num_opt("REVDEPX_INSTALL_SECONDS") %||% - cal$install_seconds %||% - 0 - -if (length(measured_runs) > 0) { - inform( - sprintf( - "Calibrated from %d run(s) (%s): checks run at %.2fx their CRAN time, %.1f min setup per shard, %.1f s per dependency installed", - length(measured_runs), - paste( - if (nzchar(local_measured)) local_measured else history$timings_runs, - collapse = ", " - ), - check_scale, - setup_minutes, - install_seconds - ) - ) -} else { - inform( - "No measured timings found; using CRAN check times as they are, with the default shard costs" - ) -} - -# What one check of each package is expected to cost *here*: what the last runs -# measured, or CRAN's time scaled to this machine. The floor keeps a package -# with an implausibly small measurement from looking free. -check_seconds <- t_total * check_scale -seen <- intersect(packages, names(measured_seconds)) -check_seconds[seen] <- measured_seconds[seen] -check_seconds <- pmax(check_seconds, 30) -timing_source <- ifelse( - packages %in% seen, - "measured", - ifelse(known, "cran", "median") -) -inform( - sum(packages %in% seen), - " of ", - length(packages), - " check times measured by an earlier run" -) - -# Weight: what one package costs a worker in wall clock. -# -# `check_seconds` is calibrated to mean *one half*: `collect.R` records the -# mean of the positive per-half durations and `calibration()` fits -# `median(seconds / T_total)` from that. The halves run back to back, so the -# package always costs both. Both halves run fresh: a stored old result is a -# second opinion (`baseline_agrees`), never a substitute, so a baseline -# changes no weight -- this is where revdep2's `((!reuse) + 1) *` factor -# would otherwise come back, and it stays retired on purpose. -halves <- 2 -weight <- halves * check_seconds / 60 + overhead_minutes - -# ------------------------------------------------------------- partitioning -- - -n <- length(packages) -# What the whole batch costs in *wall clock*: the queue works -# `parallelism` packages at once, so a shard's check minutes are its check -# load divided by the workers -- a lower bound the per-shard model below -# tightens for shards dominated by one giant. -total_check <- sum(weight) / parallelism - -# How many shards can actually run at the same time. Everything past that waits -# for a lane, so the shard count is counted in waves of this size. -lanes <- max(1L, min(as.integer(max_parallel), as.integer(max_shards), n)) - -# Two demands, and they do not agree once the batch is large: -# -# * the budget wants shards of at most `budget` check minutes -- short legs, -# quick feedback, cheap re-runs; -# * the capacity says a shard can hold `capacity` check minutes before its -# own deadline starts deferring packages. -# -# While the budget's answer fits in one wave, it wins: those shards all start -# at once, so cutting finer really does buy wall clock. Past that it stops -# buying anything -- shard 21 of 40 waits for shard 1 to finish either way, and -# arrives having paid a second setup for the privilege. So beyond one wave the -# capacity decides, and it decides in whole waves: as many as it takes to keep -# every shard under its deadline, and not one more. -by_budget <- max(1L, as.integer(ceiling(total_check / budget))) -by_capacity <- max(1L, as.integer(ceiling(total_check / max(capacity, 1)))) -# Neither dial may be violated inside a wave, so the larger of the two wins -# there; a capacity smaller than the budget is a contradiction, and the one -# that keeps shards inside their deadline is the one to honour. -k <- if (max(by_budget, by_capacity) <= lanes) { - max(by_budget, by_capacity) -} else { - lanes * as.integer(ceiling(by_capacity / lanes)) -} -max_k <- max(1L, min(as.integer(max_shards), n)) -k <- max(1L, min(k, max_k)) -inform( - sprintf( - "%d packages, ~%.0f check minutes; budget %.0f min asks for %d shard(s), capacity %.0f min needs %d, %d lane(s) -> %d shard(s), ~%.0f check min each", - n, - total_check, - budget, - by_budget, - capacity, - by_capacity, - lanes, - k, - total_check / k - ) -) - -dep_idx <- lapply(closure, function(deps) match(deps, universe)) -penalty <- install_seconds / 60 - -ord <- order(-weight) - -# The greedy pass, for a given shard count. It is cheap enough (O(n x K) with a -# bitmap per shard) to run more than once, which is what lets the deadline -# check below see a real partition rather than an average. -# -# A shard's wall clock is modelled as -# -# overhead + max(heaviest member, check sum / parallelism) -# -# The division alone would flatter a -# shard dominated by one giant -- workers cannot share a package, so a shard -# whose heaviest member outweighs everything else runs exactly as long as -# that member, however many workers idle beside it. max(heaviest, mean work -# per worker) is the standard lower bound for such a schedule, and the -# two-ended queue tracks it closely: the giants start first, the cheap tail -# packs the gaps. -partition <- function(k) { - assignment <- integer(n) - overhead <- rep(setup_minutes + length(dev_closure) * penalty, k) - check_sum <- numeric(k) - check_max <- numeric(k) - have <- matrix(FALSE, nrow = length(universe), ncol = k) - have[match(dev_closure, universe), ] <- TRUE - - place <- function(i, s) { - p <- ord[[i]] - fresh <- sum(!have[dep_idx[[p]], s]) - assignment[[p]] <<- s - check_sum[[s]] <<- check_sum[[s]] + weight[[p]] - check_max[[s]] <<- max(check_max[[s]], weight[[p]]) - overhead[[s]] <<- overhead[[s]] + fresh * penalty - have[dep_idx[[p]], s] <<- TRUE - } - - # Phase 1: the K heaviest packages, dealt round-robin. - for (i in seq_len(min(k, n))) { - place(i, i) - } - - # Phase 2: everything else goes where it costs least, dependency reuse folded - # into the price. - if (n > k) { - for (i in seq(k + 1L, n)) { - p <- ord[[i]] - fresh <- colSums(!have[dep_idx[[p]], , drop = FALSE]) - score <- overhead + - fresh * penalty + - pmax( - pmax(check_max, weight[[p]]), - (check_sum + weight[[p]]) / parallelism - ) - place(i, which.min(score)) - } - } - - check_wall <- pmax(check_max, check_sum / parallelism) - list( - assignment = assignment, - load = overhead + check_wall, - check_load = check_wall - ) -} - -# `capacity` bounds the *checks* a shard may hold; the shard also spends its -# setup and its installs inside the same deadline, and only a real partition -# says how much that is -- the install union of a shard is not a per-package -# constant. So the estimate is checked against the deadline here, and a shard -# count that cannot hold it grows by whole waves until it can. -fit <- partition(k) -while (max(fit$load) > deadline_minutes && k < max_k) { - grown <- min(as.integer(k + lanes), max_k) - inform(sprintf( - "Heaviest shard estimated at ~%.0f min, past the %.0f min deadline; growing to %d shard(s)", - max(fit$load), - deadline_minutes, - grown - )) - k <- grown - fit <- partition(k) -} -assignment <- fit$assignment -load <- fit$load -check_load <- fit$check_load -waves <- as.integer(ceiling(k / lanes)) - -# Out of room: the matrix limit (or the package count) caps the shards below -# what the work needs, so every shard would run into its deadline and defer. -# That is a plan worth refusing -- a run started this way spends hours to -# report half its packages as deferred, and says so only at the end. -# -# How many runs it takes instead: splitting into G parts divides a shard's -# check load by G, but not its setup or its installs, so the question is how -# much of the deadline is left for checks once those are paid. -plan_too_big <- function() { - worst <- which.max(load) - overhead <- load[[worst]] - check_load[[worst]] - headroom <- deadline_minutes - overhead - # A package is never split across shards, so one package heavier than the - # room a shard has is a wall that no number of parts gets around. Say which - # package, and stop recommending a split that cannot work. - giants <- names(weight)[weight > headroom] - parts <- if (headroom <= 0 || length(giants) > 0) { - NA_integer_ - } else { - max(2L, as.integer(ceiling(check_load[[worst]] / headroom))) - } - inform(sprintf( - "Too big for one run: %d shard(s) is the limit, and the heaviest would be ~%.0f min against a %.0f min deadline", - k, - load[[worst]], - deadline_minutes - )) - append_summary(c( - "## revdepx plan", - "", - "**Too big for one run — nothing was started.**", - "", - sprintf( - "%d packages need ~%.0f check minutes. At most %d shard%s can be planned (%s), which puts the heaviest at ~%.0f min: ~%.0f min of checks on top of ~%.0f min of setup and installs, against the %.0f min a shard has before its deadline starts deferring packages.", - n, - total_check, - max_k, - if (max_k == 1) "" else "s", - if (max_k < as.integer(max_shards)) { - sprintf("one per package, and there are only %d", n) - } else { - sprintf("`max-shards`, itself capped at 250 by the matrix limit") - }, - load[[worst]], - check_load[[worst]], - overhead, - deadline_minutes - ), - "", - if (headroom <= 0) { - c( - sprintf( - "Setup and installs alone (~%.0f min) already exceed the deadline, so splitting the packages will not help: raise `REVDEPX_DEADLINE_MINUTES` (and the job's `timeout-minutes`, up to GitHub's 6 h ceiling) first.", - overhead - ), - "" - ) - } else if (length(giants) > 0) { - # Naming them matters: this is the one case where the operator has to - # decide something (wait longer, or check less), and the decision is - # about these packages specifically. - c( - sprintf( - "%s alone %s more than the ~%.0f min a shard has left for checks, and a package is never split across shards — so no `part` split helps here.", - paste0("`", paste(utils::head(sort(giants), 5), collapse = "`, `"), "`"), - if (length(giants) == 1) "needs" else "need", - headroom - ), - "", - sprintf( - "Raise `REVDEPX_DEADLINE_MINUTES` (now %.0f) and the shard job's `timeout-minutes` (now 350, GitHub's ceiling is 6 h), or leave %s out of the run with an explicit `packages` list.", - deadline_minutes, - if (length(giants) == 1) "it" else "them" - ), - "" - ) - } else { - c( - sprintf("Split it into %d runs, each an independent report:", parts), - "", - "```sh", - paste0( - sprintf( - "gh workflow run %s -f part=%d/%d", - this_workflow_file, - seq_len(parts), - parts - ), - collapse = "\n" - ), - "```", - "", - paste( - "The parts are cut from the same weight-ordered list, dealt round", - "robin, so they are of similar size and together cover everything —", - "and each part re-plans itself, so a part that is still too big says", - "so in turn. They share baselines, timings and the universe image", - "through the usual channels, so the later parts start warmer than", - "the first." - ), - "" - ) - }, - "Or keep it in one run by making the shards fit:", - "", - sprintf( - "* `max-parallel` above %d does not change this — the limit is how many shards may exist (250), not how many run at once.", - max_parallel - ), - sprintf( - "* raise `shard-capacity-minutes` (now %.0f) only together with `REVDEPX_DEADLINE_MINUTES` (now %.0f) and the shard job's `timeout-minutes` (350): the deadline is what a shard actually has.", - capacity, - deadline_minutes - ), - "* pass an explicit `packages` list, or a smaller `depth`, to check less.", - "" - )) - quit(save = "no", status = 1) -} -if (max(load) > deadline_minutes) { - plan_too_big() -} - -inform(sprintf( - "%d shard(s) in %d wave(s); heaviest ~%.0f min (checks ~%.0f, deadline %.0f)", - k, - waves, - max(load), - max(check_load), - deadline_minutes -)) - -# A plan can fit the matrix and still be a bad idea: `which: most` at `depth: -# 2` is 3419 packages and about 22 hours of waves here, which is a run nobody -# is watching by the end and a day of artifacts riding on one universe image. It is -# a legitimate thing to ask for, so this warns rather than refuses -- but it -# warns where the dispatcher will see it, not only in the wave line. -wall_minutes <- waves * max(load) -long_run_hours <- env_num("REVDEPX_LONG_RUN_HOURS", 12) -long_run <- wall_minutes > long_run_hours * 60 - -# ------------------------------------------------------------------ output --- - -shard_members <- lapply(seq_len(k), function(s) { - members <- packages[assignment == s] - members[order(-weight[members])] -}) -shard_install <- lapply(shard_members, function(members) { - sort(unique(c(dev_closure, unlist(closure[members], use.names = FALSE)))) -}) - -# revdep2 computed a preflight subset here -- the packages at least two shards -# needed, because a host library was rebuilt per shard and a package one shard -# needed was built once either way. The universe image dissolved that -# arithmetic: every package is installed exactly once, into the image, and -# every shard mounts all of them. So the plan records the whole universe, and -# image.R reads it directly. - -shard_list <- lapply(seq_len(k), function(s) { - members <- shard_members[[s]] - install <- shard_install[[s]] - list( - index = s, - estimate_minutes = round(load[[s]], 1), - check_minutes = round(check_load[[s]], 1), - install_packages = length(install), - install = as.list(install), - packages = lapply(members, function(p) { - list( - name = p, - version = unname(their_version[[p]]), - level = if (p %in% names(level_of)) unname(level_of[[p]]) else 0L, - weight_minutes = round(unname(weight[[p]]), 2), - t_total = unname(t_total[[p]]), - check_seconds = round(unname(check_seconds[[p]])), - timing_source = timing_source[[match(p, packages)]], - dep_fingerprint = unname(fingerprint[[p]]), - baseline = unname(reuse[[p]]) - ) - }) - ) -}) - -# The dispatched ref and the checked-out tree differ when the `ref` input -# names another branch or SHA; the tree is what is being tested. -head_sha <- tryCatch( - system2("git", c("rev-parse", "HEAD"), stdout = TRUE, stderr = NULL)[[1]], - error = function(e) "" -) -if (!nzchar(head_sha)) { - head_sha <- env_chr("GITHUB_SHA") -} - -plan <- list( - package = package, - dev_version = dev_version, - cran_version = cran_version, - # The container's R, full and as a series: what the checks run under, and - # what every baseline row is keyed on. - r_version = r_version, - r_full_version = r_full_version, - base_image = base_image_tag, - engine = engine, - workers = workers, - sha = head_sha, - ref = env_chr("GITHUB_REF_NAME"), - which = which_input, - depth = depth_raw, - levels = as.list(level_counts), - selection = selection, - part = part, - generated_at = now_utc(), - timing_flavor = timing_flavor, - retry_of = if (nzchar(retry_run)) run_id_chr(retry_run) else "0", - baseline = list( - run_id = baseline_run, - max_age_days = baseline_max_age, - reused = sum(reuse), - fresh = sum(!reuse) - ), - calibration = list( - runs = if (nzchar(local_measured)) { - as.list(local_measured) - } else { - as.list(history$timings_runs) - }, - max_runs = max_measured_runs, - max_age_days = measured_max_age, - packages_measured = sum(packages %in% seen), - check_scale = round(check_scale, 3), - setup_minutes = round(setup_minutes, 2), - install_seconds = round(install_seconds, 2) - ), - params = list( - shard_budget_minutes = budget, - shard_capacity_minutes = capacity, - max_shards = max_shards, - max_parallel = max_parallel, - lanes = lanes, - waves = waves, - install_seconds_per_package = install_seconds, - setup_minutes = setup_minutes, - package_overhead_minutes = overhead_minutes - ), - totals = list( - revdeps = length(revdeps), - packages = n, - check_minutes = round(total_check, 1), - estimate_minutes = round(sum(load), 1), - wave_minutes = round(waves * max(load), 1), - universe = length(universe) - ), - dropped_unknown = as.list(dropped), - # Everything any shard needs installed, dev closure included: the list - # image.R installs into the universe image. - universe = as.list(sort(universe)), - dev_closure = as.list(dev_closure), - shards = shard_list -) -write_json(plan, out_path) -plan_hash <- unname(tools::md5sum(out_path)) -inform("Plan written to ", out_path) - -parallel <- max(1L, min(as.integer(max_parallel), k)) -matrix <- list( - include = lapply(shard_list, function(s) { - list( - shard = s$index, - label = sprintf( - "%d pkgs, ~%.0f min", - length(s$packages), - s$estimate_minutes - ) - ) - }) -) - -set_output("matrix", jsonlite::toJSON(matrix, auto_unbox = TRUE)) -set_output("shards", as.character(k)) -set_output("packages", as.character(n)) -set_output("max_parallel", as.character(parallel)) -set_output("baseline_run", baseline_run) -set_output("plan_hash", plan_hash) -set_output("universe_count", as.character(length(universe))) - -# ------------------------------------------------------------------ summary -- - -top <- function(s) { - names <- vapply(s$packages, function(p) p$name, character(1)) - paste(utils::head(names, 3), collapse = ", ") -} -summary_df <- data.frame( - Shard = vapply(shard_list, function(s) s$index, integer(1)), - Packages = vapply(shard_list, function(s) length(s$packages), integer(1)), - `Check est.` = sprintf( - "~%.0f min", - vapply(shard_list, function(s) s$check_minutes, numeric(1)) - ), - `Total est.` = sprintf( - "~%.0f min", - vapply(shard_list, function(s) s$estimate_minutes, numeric(1)) - ), - Installs = vapply(shard_list, function(s) s$install_packages, integer(1)), - Heaviest = vapply(shard_list, top, character(1)), - check.names = FALSE -) -append_summary(c( - "## revdepx plan", - "", - if (env_flag("REVDEPX_DRY_RUN")) c("**Dry run: planning only, no checks started.**", ""), - if (long_run) { - c( - sprintf( - "> **This plan is about %.0f hours of wall clock** — %d shards, %d waves of %d. It fits, and it will run; the note is that %s.", - wall_minutes / 60, - k, - waves, - lanes, - if (length(measured_runs) == 0) { - "nothing is measured yet, so it is priced on CRAN's times, which have run about twice the local cost \u2014 the same plan calibrated is roughly half this" - } else { - "a run this long rides on one universe image and a day of artifacts" - } - ), - sprintf( - "> A narrower `which` or `depth` is the dial; `part=i/%d` splits it into runs that each report on their own, without making the total any shorter.", - max(2L, as.integer(ceiling(wall_minutes / (long_run_hours * 60)))) - ), - "" - ) - }, - "| | |", - "| --- | --- |", - sprintf("| Package | `%s` %s (CRAN: %s) |", package, dev_version, cran_version), - sprintf("| Selection | %s |", selection_md %||% selection), - # The dispatch inputs that decide how wide the net was, echoed verbatim. - # GitHub does not show a run which inputs it was given, and a plan that says - # only "all" and a package count leaves "was that depth 2 or the whole - # closure?" unanswerable from the run page -- which is a question worth - # asking, since the two differ by hours. - sprintf( - "| Reverse dependencies | `which: %s`, `depth: %s`%s%s |", - which_input, - depth_raw, - if (identical(depth, Inf)) " (the full transitive closure)" else "", - if (length(level_counts) > 0) { - paste0( - " — ", - paste0("level ", names(level_counts), ": ", level_counts, collapse = ", ") - ) - } else { - "" - } - ), - sprintf("| Packages to check | %d (of %d revdeps) |", n, length(revdeps)), - sprintf( - "| Baseline | %s |", - if (length(baseline_manifest) > 0) { - sprintf( - "%s: %d with a second opinion, %d without", - if (has_run(baseline_run)) { - paste("run", run_link(baseline_run)) - } else { - "local" - }, - sum(reuse), - sum(!reuse) - ) - } else { - "none" - } - ), - sprintf( - "| Engine | `%s`%s |", - engine, - sprintf(" (%d workers per shard)", workers) - ), - sprintf( - "| Check platform | R %s in containers on base `%s` |", - r_full_version, - base_image_tag - ), - sprintf( - "| Universe | %d packages, baked into the shared image |", - length(universe) - ), - sprintf( - "| Cost model | %s |", - if (length(measured_runs) > 0) { - sprintf( - "measured by %s: checks at %.2f× their CRAN time, %.1f min setup per shard, %.1f s per dependency installed (%d of %d packages timed here before)", - if (nzchar(local_measured)) { - sprintf("`%s`", local_measured) - } else { - sprintf( - "run%s %s", - if (length(history$timings_runs) > 1) "s" else "", - paste( - vapply(history$timings_runs, run_link, character(1)), - collapse = ", " - ) - ) - }, - check_scale, - setup_minutes, - install_seconds, - sum(packages %in% seen), - n - ) - } else { - "uncalibrated: CRAN check times as they are, default shard costs" - } - ), - sprintf( - "| Shards | %d in %d wave(s) of %d (budget %.0f min, capacity %.0f min per shard) |", - k, - waves, - parallel, - budget, - capacity - ), - sprintf( - "| Estimated wall clock | ~%.0f min (%d wave(s) of ~%.0f min) |", - waves * max(load), - waves, - max(load) - ), - sprintf("| Estimated runner time | ~%.0f min |", sum(load)), - "", - # More than one wave means the run is bound by how many jobs may run at - # once, and that ceiling is the account's, not this workflow's: past it - # GitHub queues jobs no matter what `max-parallel` says, and a plan told it - # has more lanes than it does just cuts more shards, each paying its own - # setup while it waits. So state the fact and the condition, not a knob to - # turn. - if (waves > 1) { - c( - sprintf( - "These %d shards run %d at a time, so %d waves, ~%.0f min. Raising `max-parallel` shortens that only if the account can really run more jobs at once (GitHub queues past its own concurrency limit either way); a `part` split does not shorten it at all, since the parts compete for the same lanes.", - k, - lanes, - waves, - waves * max(load) - ), - "" - ) - }, - md_table(summary_df) -)) diff --git a/.github/workflows/revdepx/shard-prep.sh b/.github/workflows/revdepx/shard-prep.sh deleted file mode 100755 index 23d877d..0000000 --- a/.github/workflows/revdepx/shard-prep.sh +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/env bash -# Make the universe image available on a shard runner, and extract its -# library index. -# -# Three ways to an image, tried in order until one works: -# 1. pull the ref the universe job published (the normal path); -# 2. load the docker-save artifact the universe job uploads when its push -# failed (revdepx-universe-image: universe-image.tar or .tar.zst); -# 3. build a local, shard-sized image from the base image: run image.R -# with REVDEPX_UNIVERSE_OVERRIDE_SHARD so that only this shard's -# install union is installed, and commit the container as -# revdepx-universe:local-shard-. Slow, but the shard stays alive -- -# and it pays only for its own slice, not the whole universe. -# -# Whatever succeeds: the ref is written to (the file -# shard.R reads through REVDEPX_IMAGE_FILE), /opt/revdepx/lib-index.json is -# extracted to for the depfail screen, and the -# /opt/revdepx/universe-ok marker is checked -- its absence is a warning, -# not an error, because the depfail screen catches what an unfinished build -# left out. -# -# Usage: -# shard-prep.sh \ -# -# -# Environment (the local fallback only): -# REVDEPX_BASE_IMAGE - base image ref to build from -# PLAN - plan.json path -# SHARD - this shard's index -# -# Exits 0 when an image was procured, however roundabout the way; exits 1 -# only when none could be -- that is a real infrastructure failure, and the -# yaml step failing the shard on it is correct. - -set -u - -ref_in=${1:-} -fallback_dir=${2:-} -index_out=${3:?usage: shard-prep.sh } -ref_out=${4:?usage: shard-prep.sh } - -note() { - echo "shard-prep: $*" >&2 -} - -# A tag alone proves nothing: the containerd store once produced a 1336-byte -# manifest shell that docker-loaded cleanly, carried the right tag and held -# no filesystem at all (run 32148999976) -- and the shard died on it instead -# of building locally. An image is usable when its library index reads back -# non-empty; the index lands in ${index_out} as a side effect, so the rung -# that wins has already extracted it. -usable() { - mkdir -p "$(dirname "${index_out}")" - docker run --rm "$1" cat /opt/revdepx/lib-index.json \ - > "${index_out}" 2> /dev/null && [ -s "${index_out}" ] -} - -got="" - -# ------------------------------------------------------------------ 1: pull -- - -# Retried, because a registry blip on one shard of forty would otherwise -# send that one shard down the fallback path and cost it an hour of local -# building over a transient 5xx. -if [ -n "${ref_in}" ]; then - for attempt in 1 2 3; do - if docker pull "${ref_in}" >&2; then - if usable "${ref_in}"; then - got="${ref_in}" - else - # Retrying the pull would fetch the same broken content. - note "${ref_in} pulled but holds no readable library index; falling through" - fi - break - fi - note "pull of ${ref_in} failed (attempt ${attempt} of 3)" - if [ "${attempt}" -lt 3 ]; then - sleep $((attempt * 20)) - fi - done -fi - -# ------------------------------------------------------ 2: loaded artifact -- - -# The universe job's escape hatch: when its push to GHCR failed it uploaded -# the image as a docker-save artifact instead, and the yaml downloaded it -# next to us. `docker load` restores the tags the save carried, and prints -# them; the printed ref is the one to use. -if [ -z "${got}" ] && [ -n "${fallback_dir}" ]; then - tarball="" - for candidate in \ - "${fallback_dir}/universe-image.tar.zst" \ - "${fallback_dir}/universe-image.tar"; do - if [ -f "${candidate}" ]; then - tarball="${candidate}" - break - fi - done - if [ -n "${tarball}" ]; then - note "loading ${tarball}" - case "${tarball}" in - *.zst) loaded=$(zstd -dc "${tarball}" | docker load) || loaded="" ;; - *) loaded=$(docker load -i "${tarball}") || loaded="" ;; - esac - printf '%s\n' "${loaded}" >&2 - got=$(printf '%s\n' "${loaded}" | sed -n 's/^Loaded image: //p' | tail -n 1) - if [ -z "${got}" ]; then - note "docker load reported no image ref; falling through" - elif ! usable "${got}"; then - note "${got} loaded but holds no readable library index; falling through to the local build" - got="" - fi - fi -fi - -# ------------------------------------------------------- 3: local fallback -- - -if [ -z "${got}" ]; then - base=${REVDEPX_BASE_IMAGE:-} - plan=${PLAN:-plan.json} - shard=${SHARD:-} - if [ -z "${base}" ] || [ -z "${shard}" ] || [ ! -f "${plan}" ]; then - note "no image to pull or load, and the local fallback is missing its inputs (REVDEPX_BASE_IMAGE='${base}', SHARD='${shard}', PLAN='${plan}')" - exit 1 - fi - note "building a local image for shard ${shard} from ${base} -- slower than a pull, but the shard stays alive" - - for attempt in 1 2 3; do - if docker pull "${base}" >&2; then - break - fi - note "pull of the base image ${base} failed (attempt ${attempt} of 3); a local copy may still serve" - if [ "${attempt}" -lt 3 ]; then - sleep $((attempt * 20)) - fi - done - - script_dir=$(cd "$(dirname "$0")" && pwd) - plan_dir=$(cd "$(dirname "${plan}")" && pwd) - plan_abs="${plan_dir}/$(basename "${plan}")" - scratch="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/revdepx-universe-fallback" - # The container's /tmp lives on the host, not in the rw layer: killed - # subprocesses leave their build trees in /tmp, and `docker commit` would - # otherwise copy that residue into the image. - scratch_tmp="${scratch}-tmp" - mkdir -p "${scratch}" "${scratch_tmp}" - # A /tmp needs 1777: apt-key writes its temporary config there, and with - # a plain 755 directory every repository fails signature verification and - # every apt-get the build runs fails with it. - chmod 1777 "${scratch_tmp}" - cidfile="${scratch}/cid" - rm -f "${cidfile}" - - # The container runs image.R exactly as the universe job would, except that - # the override limits the install to this shard's union. Root, as image - # builds are; no --rm, because the exited container is what gets committed. - # depfail.json and build-report.md land in the scratch directory -- they - # are this runner's log material, not an artifact; the depfail screen works - # from the committed index either way. The REVDEPX_* knobs are forwarded by - # name where set, so the yaml's install/load budgets apply here too. - knob_args=() - for knob in \ - REVDEPX_INSTALL_CHUNK \ - REVDEPX_INSTALL_TIMEOUT_MINUTES \ - REVDEPX_INSTALL_DEADLINE_MINUTES \ - REVDEPX_JOB_DEADLINE_MINUTES \ - REVDEPX_LOAD_TIMEOUT_MINUTES \ - REVDEPX_LOAD_JOBS \ - REVDEPX_LOAD_SWEEP_MINUTES \ - REVDEPX_METADATA_PROBE \ - REVDEPX_METADATA_TIMEOUT_MINUTES \ - REVDEPX_SYSREQS_TIMEOUT_MINUTES; do - if [ -n "${!knob+x}" ]; then - knob_args+=(-e "${knob}") - fi - done - if docker run \ - --cidfile "${cidfile}" \ - --memory 12g --memory-swap 12g \ - -v "${script_dir}:/revdepx/scripts:ro" \ - -v "${plan_abs}:/revdepx/plan.json:ro" \ - -v "${scratch}:/revdepx/out" \ - -v "${scratch_tmp}:/tmp" \ - -e PLAN=/revdepx/plan.json \ - -e OUT_DIR=/revdepx/out \ - -e PKG_SYSREQS=true \ - -e REVDEPX_UNIVERSE_OVERRIDE_SHARD="${shard}" \ - -e REVDEPX_BASE_IMAGE="${base}" \ - ${knob_args[@]+"${knob_args[@]}"} \ - "${base}" \ - Rscript /revdepx/scripts/image.R >&2; then - got="revdepx-universe:local-shard-${shard}" - if ! docker commit "$(cat "${cidfile}")" "${got}" >&2; then - note "committing the fallback container failed" - exit 1 - fi - docker rm "$(cat "${cidfile}")" > /dev/null 2>&1 || true - else - note "the fallback build failed; see its log above" - if [ -s "${cidfile}" ]; then - docker rm -f "$(cat "${cidfile}")" > /dev/null 2>&1 || true - fi - exit 1 - fi -fi - -# ------------------------------------------------- index and sanity checks -- - -mkdir -p "$(dirname "${index_out}")" "$(dirname "${ref_out}")" - -# Rungs 1 and 2 already proved their image usable (or fell through); the -# locally built one gets the same test, and failing it here is right -- -# there is no further rung, and limping on would only move the failure into -# the driver where it is harder to read. -if ! usable "${got}"; then - note "the image ${got} has no readable /opt/revdepx/lib-index.json; it is not a universe image" - exit 1 -fi - -if ! docker run --rm "${got}" test -f /opt/revdepx/universe-ok \ - > /dev/null 2>&1; then - note "WARNING: ${got} lacks /opt/revdepx/universe-ok -- the build may not have run to its end; proceeding, the depfail screen will name what is missing" -fi - -printf '%s\n' "${got}" > "${ref_out}" -note "using image ${got}" -exit 0 diff --git a/.github/workflows/revdepx/shard.R b/.github/workflows/revdepx/shard.R deleted file mode 100644 index 1460e84..0000000 --- a/.github/workflows/revdepx/shard.R +++ /dev/null @@ -1,1027 +0,0 @@ -# Check one shard of a revdepx plan: many reverse dependencies, one job, -# every `R CMD check` inside a container. -# -# The engine-agnostic shard driver. The dependency library is not installed -# here any more -- it is baked into the run's universe image at -# /opt/revdepx/lib, built once and shared by every shard -- so the old -# `install` phase has shrunk into `prepare`: build the two one-package half -# libraries (the CRAN release of the package under test, and the dev binary) -# on the host, each by a short run of that same image, ready to be -# bind-mounted at /revdepx/lib-half in front of the baked library. The two -# phases exist so that the workflow can put each in its own step and Actions -# can time them separately; `PHASE=all` runs both in one go, which is what a -# local invocation wants. -# -# The check phase: -# -# queue -- one line per runnable package into a queue file, heaviest first; -# revdep4/queue.sh drains it with a pool of workers, each running -# a package's two halves in turn and then a per-package -# compare-one.R -- which sources the same compare.R this driver -# does and appends the manifest line itself. -# -# Either way the two results are compared per package, revdepcheck-style, -# with the functions in compare.R. -# -# Failure is data here, never a job failure: a package that breaks, times out, -# or cannot even install its dependencies gets a manifest entry saying so, and -# the walk continues. The job goes red only when the driver itself is broken. -# -# The shard stops starting new checks when its deadline says the next one will -# not finish, and records the rest as deferred; a later run started with -# `retry-run` picks exactly those up. Results that exist by then -- including -# an old-version result whose new-version counterpart was cut off -- are still -# uploaded, so nothing decided is lost to the deadline. (The queue engine's -# workers apply the same defer rule per claim, inside queue.sh.) -# -# What each phase costs is recorded in timing.json next to the results: the -# collector folds it into the run's timings artifact, and the next plan sizes -# its shards from what this one measured rather than from CRAN's numbers and a -# guess. -# -# Environment variables: -# SHARD - shard index from plan.json (required) -# PLAN - plan file (default: plan.json) -# PKG_DIR - the revdepx-pkg artifact: meta.json, bin/ (required) -# BASELINE_DIR - the revdepx-baseline artifact of the donor run; -# may be missing or empty, then everything is fresh -# OUT_DIR - results directory, uploaded as the shard artifact -# (default: results) -# REVDEPX_IMAGE_FILE - file holding the universe image reference, -# written by shard-prep.sh -# (default: $RUNNER_TEMP/revdepx-image-ref) -# REVDEPX_LIB_INDEX - the image's library index, extracted from the -# image by shard-prep.sh -# (default: $RUNNER_TEMP/revdepx-lib-index.json) -# TIMEOUT_FACTOR - per-half check timeout as a multiple of the -# package's CRAN check time (default: 1.5) -# TIMEOUT_MIN_MINUTES - floor for that timeout; CRAN's machines are not -# these runners (default: 20 in the workflow) -# DEADLINE_MINUTES - stop starting new checks past this (default: 300) -# PHASE - "prepare", "check", or "all" (default): which half -# of the shard this invocation runs ("install" is -# accepted as an alias for "prepare") -# CHECK_SLICE - `i/n`: which slice of the check phase this -# invocation runs (default: all of it in one go) - -script_dir <- dirname(sub( - "--file=", - "", - grep("^--file=", commandArgs(), value = TRUE) -)) -source(file.path(script_dir, "util.R")) -source(file.path(script_dir, "compare.R")) - -script_started <- Sys.time() -elapsed <- function(from) { - round(as.numeric(difftime(Sys.time(), from, units = "secs")), 1) -} - -shard_index <- as.integer(env_chr("SHARD")) -stopifnot(!is.na(shard_index)) -plan <- read_json(env_chr("PLAN", "plan.json")) -pkg_dir <- env_chr("PKG_DIR", "pkg") -baseline_dir <- env_chr("BASELINE_DIR", "baseline") -out_dir <- env_chr("OUT_DIR", "results") -timeout_factor <- env_num("TIMEOUT_FACTOR", 1.5) -timeout_min_sec <- env_num("TIMEOUT_MIN_MINUTES", 10) * 60 -deadline <- Sys.time() + env_num("DEADLINE_MINUTES", 300) * 60 - -# The queue engine is the only one: the pair engine (revdep3) was retired -# with its unmerged PR. The constant keeps the manifest, plan and timings -# schema -- whose rows are engine-tagged -- unchanged. -engine <- "queue" - -mine <- Filter(function(s) s$index == shard_index, plan$shards) -if (length(mine) == 0) { - stop( - "Plan has no shard ", - shard_index, - " (it has ", - length(plan$shards), - "); the plan and the matrix disagree", - call. = FALSE - ) -} -shard <- mine[[1]] -members <- vapply(shard$packages, function(p) p$name, character(1)) -meta <- read_json(file.path(pkg_dir, "meta.json")) -package <- plan$package - -dir.create(file.path(out_dir, "pkgs"), recursive = TRUE, showWarnings = FALSE) -manifest_path <- file.path(out_dir, "manifest.ndjson") -runner_temp <- env_chr("RUNNER_TEMP", tempdir()) -work <- file.path(runner_temp, "revdepx-work") -dir.create(work, recursive = TRUE, showWarnings = FALSE) - -# What shard-prep.sh left behind: the universe image's reference (a file, so -# the workflow does not have to thread it through job outputs), and the -# image's library index, extracted onto the host for the depfail screen. -image_ref_file <- env_chr( - "REVDEPX_IMAGE_FILE", - file.path(runner_temp, "revdepx-image-ref") -) -lib_index_path <- env_chr( - "REVDEPX_LIB_INDEX", - file.path(runner_temp, "revdepx-lib-index.json") -) -read_image_ref <- function() { - if (!file.exists(image_ref_file)) { - stop( - "No universe image reference at ", - image_ref_file, - "; shard-prep.sh writes it, and it did not run", - call. = FALSE - ) - } - trimws(readLines(image_ref_file, warn = FALSE)[[1]]) -} - -# Which half of the shard this invocation runs. The workflow calls the driver -# twice so that Actions times the preparation and the checks separately; -# `all` is for running the whole shard in one process, which is what a local -# invocation wants. The prepare phase leaves `install-state.json` behind and -# the checks read it, so the split costs one small file and repeats nothing. -phase <- env_chr("PHASE", "all") -if (identical(phase, "install")) { - # The phase's name when it installed a whole dependency library on the - # host; kept as an alias so nothing breaks while workflows move over. - phase <- "prepare" -} -if (!phase %in% c("all", "prepare", "check")) { - stop("PHASE must be one of \"all\", \"prepare\", \"check\"", call. = FALSE) -} -do_prepare <- phase %in% c("all", "prepare") -do_check <- phase %in% c("all", "check") -install_state <- file.path(work, "install-state.json") - -# Which slice of the shard's packages this invocation checks, as `i/n`. -# -# The driver has always written its results as it goes -- one manifest line per -# package, appended -- so that a shard killed part way through still accounts -# for what it finished. That only helps if someone *uploads* them, and the -# upload was one step at the very end. Shard 16 of run 31951756102 got three -# minutes into a 196-minute check budget before its runner was reclaimed: -# -# ##[error]The runner has received a shutdown signal. -# ##[error]Process completed with exit code 143. -# -# `if: always()` cannot help there -- a reclaimed runner runs nothing further, -# so the upload was skipped and all 87 packages came back `missing`. Slicing -# the check phase into several steps, each followed by an upload, bounds that -# loss to one slice. The slices share `OUT_DIR`, and the artifact is overwritten -# under one name, so the last upload to survive carries everything before it. -check_slice <- local({ - raw <- trimws(env_chr("CHECK_SLICE")) - if (!nzchar(raw)) { - return(list(index = 1L, of = 1L)) - } - parts <- suppressWarnings(as.integer(strsplit(raw, "/", fixed = TRUE)[[1]])) - if ( - length(parts) != 2 || - anyNA(parts) || - parts[[1]] < 1 || - parts[[2]] < 1 || - parts[[1]] > parts[[2]] - ) { - stop("CHECK_SLICE must be `i/n` with 1 <= i <= n, not ", raw, call. = FALSE) - } - list(index = parts[[1]], of = parts[[2]]) -}) -last_slice <- check_slice$index == check_slice$of - -inform( - "Shard ", - shard_index, - ": ", - length(members), - " package(s), ", - "estimated ~", - shard$estimate_minutes, - " min" -) - -# The running state per package; every entry ends up as one manifest line. -# The entry template lives in compare.R (`manifest_entry_defaults()`), shared -# with the queue engine's compare-one.R, which builds the same entries in its -# own process. -state <- new.env(parent = emptyenv()) -for (p in shard$packages) { - assign(p$name, manifest_entry_defaults(p$name, p, shard_index), envir = state) -} -update <- function(name, ...) { - entry <- get(name, envir = state) - entry[names(list(...))] <- list(...) - assign(name, entry, envir = state) - entry -} - -# ---------------------------------------------------------------- prepare ---- - -# The two half libraries, on the host. Each holds exactly one package -- the -# CRAN release of the package under test for `old`, the dev binary for `new` -# -- and check-half.sh bind-mounts the right one read-only into each check -# container at /revdepx/lib-half, in front of the image's baked dependency -# library (`R_LIBS=/revdepx/lib-half:/opt/revdepx/lib`). Same cascade as -# revdep2's host libraries, across a bind mount: the two halves see libraries -# that differ in exactly the package under test, and nothing is installed or -# uninstalled between checks. -lib_old <- file.path(work, "lib-old") -lib_new <- file.path(work, "lib-new") - -if (do_prepare) { - # There is no dependency union to install and no sysreqs to fetch: the - # universe image carries the whole dependency library, its system - # requirements, and the eviction of the package under test, all settled - # when the image was built. What is left of the old install phase is the - # two one-package half libraries, each populated by a short run of that - # same image -- so everything about them (R version, platform, compilers) - # matches the checks exactly. - libs_started <- Sys.time() - image_ref <- read_image_ref() - inform("Preparing shard ", shard_index, " against ", image_ref) - - # The dev binary must have been built for the container's R, or `R CMD - # INSTALL` below would either refuse it or, worse, install something the - # containers cannot load. Both sides of this comparison are the container - # series by construction -- build.R ran inside a container of the same base - # image, and the plan recorded the series the run resolved -- so a mismatch - # means the artifacts come from different runs. - if (!identical(as.character(meta$r_version), as.character(plan$r_version))) { - stop( - "The dev binary was built for R ", - meta$r_version, - ", but this run's containers run R ", - plan$r_version, - "; the revdepx-pkg artifact and the plan disagree", - call. = FALSE - ) - } - - dir.create(lib_old, recursive = TRUE, showWarnings = FALSE) - dir.create(lib_new, recursive = TRUE, showWarnings = FALSE) - - # The CRAN release, installed by the container's own pak. The script is - # written here and bind-mounted in, rather than passed as one long `-e`, - # so the job log and a local reproduction can read what ran. - # - # The repositories are set inside the script rather than trusted from the - # image: the image's baked default may be a frozen p3m snapshot from the - # day the universe library was built, and the old half must be whatever - # CRAN serves *today* -- that is the release the plan fingerprinted. The - # distribution codename is read from the container's own /etc/os-release, - # so the binary repository matches the platform doing the installing. - old_script <- file.path(work, "install-old.R") - writeLines( - c( - sprintf( - "# Written by shard.R: install the CRAN release of %s into the", - package - ), - "# half library mounted at /revdepx/lib-half.", - "os_release <- readLines('/etc/os-release', warn = FALSE)", - "line <- grep('^VERSION_CODENAME=', os_release, value = TRUE)", - "if (length(line) == 0) stop('no VERSION_CODENAME in /etc/os-release')", - "codename <- gsub('\"', '', sub('^VERSION_CODENAME=', '', line[[1]]))", - "options(repos = c(CRAN = sprintf(", - " 'https://p3m.dev/cran/__linux__/%s/latest',", - " codename", - ")))", - "# The baked library already satisfies every dependency; on the path it", - "# keeps pak from installing the dependency tree over again into the", - "# half library, which must end up holding exactly one package.", - ".libPaths(c('/revdepx/lib-half', '/opt/revdepx/lib', .libPaths()))", - sprintf( - "pak::pkg_install('%s', lib = '/revdepx/lib-half', upgrade = FALSE)", - package - ) - ), - old_script - ) - inform( - "Installing ", - package, - " ", - plan$cran_version, - " into the old half library" - ) - # As root, like every image-side install: it writes to a bind mount and - # nothing about the container outlives the run (`--rm`; the captured output - # below is all the forensics a failed install needs). Bounded by coreutils - # `timeout` rather than run_with_timeout() -- there is no R child to - # supervise, just one docker client -- at 15 minutes, many times what this - # install has ever taken. The output streams into the job log. - status <- system2( - "timeout", - c( - "900", - "docker", - "run", - "--rm", - "-v", - shQuote(paste0(normalizePath(lib_old), ":/revdepx/lib-half")), - "-v", - shQuote(paste0(normalizePath(old_script), ":/revdepx/install-old.R:ro")), - shQuote(image_ref), - "Rscript", - "/revdepx/install-old.R" - ) - ) - if (status != 0) { - stop( - "Installing the CRAN release of ", - package, - " failed (exit ", - status, - ")", - call. = FALSE - ) - } - # What actually landed, read off the host side of the bind mount: the - # repositories can lag CRAN, and the manifest records what was really - # checked against. - old_desc <- file.path(lib_old, package, "DESCRIPTION") - if (!file.exists(old_desc)) { - stop( - "The install container exited 0, but ", - old_desc, - " does not exist", - call. = FALSE - ) - } - our_cran_version <- unname(read.dcf(old_desc, fields = "Version")[1, 1]) - if (!identical(our_cran_version, plan$cran_version)) { - inform( - "Note: old checks run against ", - our_cran_version, - " (the repositories lag CRAN, the plan expected ", - plan$cran_version, - ")" - ) - } - - inform( - "Installing dev binary ", - basename(meta$binary), - " into the new half library" - ) - status <- system2( - "timeout", - c( - "900", - "docker", - "run", - "--rm", - "-v", - shQuote(paste0(normalizePath(pkg_dir), ":/revdepx/pkg:ro")), - "-v", - shQuote(paste0(normalizePath(lib_new), ":/revdepx/lib-half")), - shQuote(image_ref), - "R", - "CMD", - "INSTALL", - "-l", - "/revdepx/lib-half", - shQuote(file.path("/revdepx/pkg", meta$binary)) - ) - ) - if (status != 0) { - stop("Installing the prebuilt dev binary failed", call. = FALSE) - } - our_dev_version <- meta$dev_version - - install_seconds <- elapsed(libs_started) - inform( - "Half libraries ready after ", - round(install_seconds / 60, 1), - " min" - ) - - # What the check phase needs to know about this one, and what the timings at - # the end report. Everything else it can work out for itself from what it - # finds on disk. - write_json( - list( - # The field names are the host-library era's, kept verbatim: the - # collector's calibration reads them off every run's timings, and the - # numbers that are structurally zero now -- nothing is restored on the - # host, the dependency library ships inside the image -- still have to - # be zero *under the same names* to stay comparable across runs. - install_packages = 0L, - restored = 0L, - restore_seconds = 0, - install_seconds = install_seconds, - our_cran_version = our_cran_version, - our_dev_version = our_dev_version, - image = image_ref, - # When the shard's clock started, and what the prepare phase spent of - # it. Both matter to the phase that follows: it has to finish inside the - # same job, and its own `script_seconds` is no longer the whole driver. - started_at = format(script_started, "%Y-%m-%dT%H:%M:%SZ", tz = "UTC"), - phase_seconds = elapsed(script_started) - ), - install_state - ) -} - -if (!do_check) { - inform("Prepare phase complete; the check phase runs as its own step") - quit(save = "no", status = 0) -} - -# A prepare phase that never finished leaves no state, and there is nothing -# to check without the two half libraries it builds. But every package still -# has to be *accounted for*: `missing` -- which is what the collector reports -# for a shard that uploaded nothing -- says only that a job died, while a -# manifest full of `error` says which shard, and why, and is picked up by -# `retry-run` just the same. Shard 3 of run 31893156685 lost 50 packages to -# exactly this. -if (!file.exists(install_state)) { - reason <- sprintf( - "shard %d: the prepare phase did not finish, so nothing could be checked", - shard_index - ) - inform(reason) - invisible(file.create(manifest_path)) - for (name in members) { - update(name, result = "error", message = reason) - write_manifest_line( - get(name, envir = state), - manifest_path, - plan$cran_version, - plan$dev_version - ) - } - append_summary(c( - if (check_slice$of > 1L) { - sprintf("### Shard %d, slice %d/%d", shard_index, check_slice$index, check_slice$of) - } else { - sprintf("### Shard %d", shard_index) - }, - "", - sprintf("%d package(s) not checked: %s.", length(members), reason) - )) - quit(save = "no", status = 0) -} -installed_state <- read_json(install_state) -our_cran_version <- installed_state$our_cran_version -our_dev_version <- installed_state$our_dev_version - -# The deadline belongs to the *shard*, not to this process. -# -# `deadline` was computed at the top of the script, so the check phase gave -# itself a fresh 300 minutes on top of whatever the prepare phase had already -# spent -- and the job's own `timeout-minutes` covers their sum. A shard with -# a long preparation could then be killed mid-check by Actions instead of -# stopping itself and deferring, which is the one thing the deadline exists to -# prevent. Rebased on when the prepare phase started. -shard_started <- tryCatch( - as.POSIXct( - installed_state$started_at, - format = "%Y-%m-%dT%H:%M:%SZ", - tz = "UTC" - ), - error = function(e) NA -) -if (!is.na(shard_started)) { - deadline <- shard_started + env_num("DEADLINE_MINUTES", 300) * 60 - inform(sprintf( - "Prepare phase took %s; %s of the shard's deadline left for checks", - format_duration(installed_state$phase_seconds %||% 0), - format_duration(max(0, as.numeric(deadline - Sys.time(), units = "secs"))) - )) -} -# Create, NEVER truncate: file.create() zeroes an existing file, and the -# slices share this manifest. Run 32114635495 lost two thirds of its results -# to exactly this line -- every slice wiped its predecessors' lines at -# startup, and the account-for-everything sweep then faithfully re-wrote -# those packages as `deferred`, erasing 2293 finished checks. The sweep at -# the end was already written to leave existing lines alone; it just never -# got to see them. -if (!file.exists(manifest_path)) { - invisible(file.create(manifest_path)) -} - -# What a check will be able to load, which is nothing this host has -# installed: the dependency library lives inside the universe image, at -# /opt/revdepx/lib, and this driver process never sees it. shard-prep.sh -# extracts the image's own index -- the container library's manifest, written -# when the image was built -- and that is what the depfail screen reads. The -# package under test is deliberately *not* in the baked library (it was -# evicted at image build time, so neither half's cascading library can be -# shadowed by it) and lives in `lib-old` and `lib-new` instead, so it is -# added back by name; the base and recommended packages ship with the -# container's R, as with any R. -if (!file.exists(lib_index_path)) { - stop( - "No library index at ", - lib_index_path, - "; shard-prep.sh extracts it from the universe image, and it did not run", - call. = FALSE - ) -} -lib_index <- read_json(lib_index_path) -installed <- unique(c( - vapply(lib_index$packages, function(p) p$package, character(1)), - package, - base_packages() -)) - -# A package whose *strong* dependency closure is incomplete cannot produce a -# check result worth comparing; missing suggests are tolerable, the check runs -# with _R_CHECK_FORCE_SUGGESTS_=false, the way CRAN treats unavailable ones. -# The dependency metadata spans CRAN and Bioconductor, like the planner's: -# a Bioconductor dependency the image build could not deliver fails the -# package here, as `depfail`, instead of costing two checks to learn -# `depmissing`. -db <- dep_db() -strong_missing <- function(name) { - strong <- tools::package_dependencies( - name, - db = db, - which = "strong", - recursive = TRUE - )[[1]] - setdiff(intersect(strong, rownames(db)), c(installed, base_packages())) -} -runnable <- character() -for (name in members) { - missing <- tryCatch(strong_missing(name), error = function(e) character()) - if (length(missing) > 0) { - update( - name, - result = "depfail", - message = paste( - "Dependencies not installed:", - paste(missing, collapse = ", ") - ) - ) - inform( - name, - ": dependencies missing (", - paste(missing, collapse = ", "), - ")" - ) - } else { - runnable <- c(runnable, name) - } -} - -# ------------------------------------------------------------------ slice ---- - -# Dealt round robin rather than in blocks. `runnable` is heaviest first, so a -# contiguous cut would put every long check in the first slice and leave the -# last one with nothing but the cheap ones -- and the deadline, which stops the -# shard when the next check will not fit, would then bite unevenly. Round robin -# gives every slice the same mix. -# -# Cut before the downloads, not after: every slice derives the same screened, -# heaviest-first list from the same plan, so the deal is stable, and slicing -# first means each slice downloads only its own tarballs instead of the whole -# shard's three times over. -# -# Not `seq(index, length(runnable), by = of)`: seq() refuses a `from` past -# `to` ("wrong sign in 'by' argument"), so that spelling is an R *error* for -# a shard with fewer runnable packages than slices -- a 1-package shard, the -# common retry case, crashed slices 2 and 3 -- and for an empty `runnable` -# (say, a half-built universe image depfailing everything) it crashed slice 1 -# before a single manifest line was written, turning recorded diagnoses into -# `missing`. -if (check_slice$of > 1L) { - mine <- seq_along(runnable) - mine <- mine[mine %% check_slice$of == check_slice$index %% check_slice$of] - inform(sprintf( - "Slice %d/%d: %d of this shard's %d runnable package(s)", - check_slice$index, - check_slice$of, - length(mine), - length(runnable) - )) - runnable <- runnable[mine] -} - -# ---------------------------------------------------------------- sources ---- - -src_dir <- file.path(work, "src") -dir.create(src_dir, showWarnings = FALSE) -sources <- list() -for (name in runnable) { - tarball <- tryCatch( - { - hit <- utils::download.packages( - name, - destdir = src_dir, - repos = cran_repo(), - type = "source", - quiet = TRUE - ) - hit[1, 2] - }, - error = function(e) NULL - ) - if (is.null(tarball)) { - update( - name, - result = "error", - message = "Source tarball could not be downloaded" - ) - inform(name, ": source download failed") - } else { - sources[[name]] <- tarball - actual <- sub( - sprintf("^%s_(.*)[.]tar[.]gz$", name), - "\\1", - basename(tarball) - ) - update(name, version = actual) - } -} -runnable <- names(sources) - -# ------------------------------------------------------------------ checks --- - -# The check containers must run the exact image the half libraries were -# prepared against, so the ref recorded by the prepare phase wins; the ref -# file is the fallback for a hand-driven PHASE=check over an existing work -# directory. Exported because queue.sh passes it to check-half.sh, which -# passes it to `docker run`. -image_ref <- installed_state$image %||% read_image_ref() -Sys.setenv(REVDEPX_IMAGE = image_ref) -inform(sprintf( - "Checking old (%s) and new (%s) with the %s engine, image %s", - our_cran_version, - our_dev_version, - engine, - image_ref -)) - -# The timeout scales with what the check costs CRAN, floored because these -# runners are slower than CRAN's machines and a tiny package must not be -# killed over the difference. It is a *per-half* clock: the queue hands it -# to the old and the new half in turn. -package_timeout_sec <- function(name) { - max( - timeout_min_sec, - timeout_factor * (get(name, envir = state)$t_total %||% 0) - ) -} - -# How much of a package's diff goes into the job log before it is cut off. -diff_max_lines <- env_num("REVDEPX_DIFF_MAX_LINES", 200) - -# How much of an installation or test transcript goes into the job summary. -# Both are read to find out why something broke, and 80 lines -- the default -# for the check log, which is a summary of stages -- cuts a compiler error or a -# testthat run off in the middle. -detail_max_lines <- env_num("REVDEPX_DETAIL_MAX_LINES", 300) - -checks_started <- 0L -check_seconds <- 0 -reported <- character() - -# The queue engine: this driver writes the work list and hands it to -# revdep4/queue.sh, whose workers run the two halves of a package one after -# the other and then a per-package compare-one.R -- sourcing the same -# compare.R as this script -- which appends the manifest line itself, under -# flock, as each package finishes. Write-as-you-go, so a killed shard still -# accounts for what it finished. This process only writes the deferred tail afterwards. -queue_sh <- file.path(dirname(script_dir), "revdep4", "queue.sh") -if (!file.exists(queue_sh)) { - stop( - "REVDEPX_ENGINE=queue needs ", - queue_sh, - ", which does not exist; is the revdep4 directory checked out?", - call. = FALSE - ) -} - -# One line per runnable package: name, tarball, per-half timeout seconds, -# and the plan's weight in minutes (queue.sh defers on it near the -# deadline). `runnable` is already heaviest first -- the plan deals shard -# members that way and nothing above reorders them -- which is what -# queue.sh's two cursors rely on: one worker eats from the heavy end, the -# rest from the light end, so a giant cannot strand a tail of cheap -# packages behind it. -# -# Both halves always run fresh. A -# stored old result the plan certified as comparable is read back by -# compare-one.R purely as a second opinion (`baseline_agrees`) -- never as -# a substitute for the old check, however tempting the saved wall clock: a -# fresh old is the only result whose provenance this run controls, and the -# second opinion is exactly how a discrepancy in the stored one gets -# noticed rather than trusted. -second_opinions <- sum(vapply( - runnable, - function(name) { - isTRUE(get(name, envir = state)$baseline_planned) && - file.exists(file.path(baseline_dir, "old-rds", paste0(name, ".rds"))) - }, - logical(1) -)) -queue_file <- file.path( - work, - sprintf("queue-slice-%d.tsv", check_slice$index) -) -writeLines( - vapply( - runnable, - function(name) { - entry <- get(name, envir = state) - paste( - name, - sources[[name]], - format(round(package_timeout_sec(name)), scientific = FALSE), - format( - max(entry$weight_minutes %||% 0, 0), - scientific = FALSE, - trim = TRUE - ), - sep = "\t" - ) - }, - character(1) - ), - queue_file -) -inform(sprintf( - "Queue: %d package(s), %d with a stored old result as a second opinion", - length(runnable), - second_opinions -)) - -queue_work <- file.path(work, "check") -dir.create(queue_work, recursive = TRUE, showWarnings = FALSE) -# What the prepare phase installed into the two half libraries; queue.sh -# forwards these to compare-one.R (and stamps its last-resort fallback -# lines with them), so every manifest line carries the versions. -Sys.setenv( - REVDEPX_OUR_CRAN_VERSION = our_cran_version, - REVDEPX_OUR_DEV_VERSION = our_dev_version -) -status <- system2( - queue_sh, - shQuote(c( - queue_file, - queue_work, - lib_old, - lib_new, - manifest_path, - format(round(as.numeric(deadline)), scientific = FALSE) - )) -) -if (!identical(status, 0L)) { - # queue.sh promises to always exit 0, so anything else means it never - # reached its own last line. Whatever the workers did write is on disk - # and is read back below; the packages without lines become deferred. - inform( - "queue.sh exited with status ", - status, - "; reading back what it left" - ) -} - -# The queue's forensics -- who claimed what, and the closing tallies -- go -# into the results artifact, named per slice so later slices do not -# overwrite them. Left in the work directory alone they die with the -# runner, which is exactly when they are wanted. -for (record in c("claimed.log", "queue-state.json")) { - from <- file.path(queue_work, record) - if (file.exists(from)) { - file.copy( - from, - file.path( - out_dir, - sprintf("queue-slice-%d-%s", check_slice$index, record) - ), - overwrite = TRUE - ) - } -} - -# ---------------------------------------------------------------- manifest --- - -# Whatever nothing wrote a line for: deferred packages, and the ones a depfail -# or a missing source knocked out before anything started. -# -# Under slicing this also covers the packages belonging to *later* slices, -# which is deliberate: an interim artifact that says `deferred` for them is -# the truth at that moment, and better than the `missing` the collector would -# otherwise reconcile them into. What it must not do is overwrite a result -# already on disk -- an earlier slice's line, or under the queue engine a line -# a worker's compare-one.R appended -- those packages are still `deferred` in -# this process's memory, and a later line wins in the collector. So the -# manifest is read back and anything already accounted for is left alone. -read_manifest <- function() { - if (!file.exists(manifest_path)) { - return(list()) - } - lines <- readLines(manifest_path, warn = FALSE) - lines <- lines[nzchar(trimws(lines))] - lapply(lines, function(line) jsonlite::fromJSON(line, simplifyVector = FALSE)) -} -already <- vapply(read_manifest(), function(e) e$package, character(1)) -for (name in setdiff(members, c(reported, already))) { - write_manifest_line( - get(name, envir = state), - manifest_path, - our_cran_version, - our_dev_version - ) -} -# The summary below is this slice's, not the shard's: the other slices' -# packages are still at their initial `deferred` in this process and would pad -# every table with rows that say nothing. -# The queue's results were written by compare-one.R in other processes, so -# this driver's `state` never saw them; the manifest on disk is the source -# of truth. Later lines win per package, matching the collector. -by_package <- list() -for (e in read_manifest()) { - by_package[[e$package]] <- e -} -slice_names <- if (check_slice$of > 1L) runnable else members -entries <- lapply( - intersect(slice_names, names(by_package)), - function(name) by_package[[name]] -) -# What this slice's checks cost, read back the same way rather than -# accumulated in-process. `check_seconds` sums true per-half seconds -# (t_old + t_new) over the lines this slice produced, and `checks_started` -# counts halves run -- one for each positive t. -ran <- lapply( - intersect(runnable, names(by_package)), - function(name) by_package[[name]] -) -check_seconds <- sum(vapply( - ran, - function(e) (e$t_old %||% 0) + (e$t_new %||% 0), - numeric(1) -)) -checks_started <- as.integer(sum(vapply( - ran, - function(e) ((e$t_old %||% 0) > 0) + ((e$t_new %||% 0) > 0), - numeric(1) -))) - -# ----------------------------------------------------------------- timings --- - -# What this shard cost, next to what the plan thought it would: the collector -# pools these into the run's timings artifact, and the next plan calibrates its -# cost model from them. The job's own minutes -- the runner image, the image -# pull, the artifact downloads before this script even starts -- are not -# visible from here; the collector reads those off the API and adds them. -# `checks` and `check_seconds` mean halves run and summed per-half seconds. -# Historical rows from the retired pair engine tallied pairs and pair wall -# clocks instead; calibration() keys on the engine tag, so they never mix. -# Across slices, not per slice: the collector fits the cost model from these, -# and a `check_seconds` covering a third of the shard next to a `script_seconds` -# covering the job would make every shard look three times cheaper than it is. -earlier <- if (file.exists(file.path(out_dir, "timing.json"))) { - tryCatch(read_json(file.path(out_dir, "timing.json")), error = function(e) { - NULL - }) -} else { - NULL -} -write_json( - list( - index = shard_index, - packages = length(members), - checks = checks_started + (earlier$checks %||% 0L), - install_packages = installed_state$install_packages, - restored = installed_state$restored, - restore_seconds = installed_state$restore_seconds, - install_seconds = installed_state$install_seconds, - check_seconds = round(check_seconds + (earlier$check_seconds %||% 0), 1), - # Both phases AND every earlier slice, because the collector fits - # `setup_minutes` as `job_minutes - script_minutes` -- the minutes before - # the driver starts. Reporting only this process would charge the prepare - # phase and the earlier slices' driver time to "setup"; with three slices - # that hands up to two thirds of the shard's check minutes to the fixed - # cost, which the plan then seeds every shard's load with, and a setup of - # tens of minutes instead of a few is what tips a plan into extra waves. - # `earlier$script_seconds` already carries the prepare phase from slice 1, - # so it replaces `phase_seconds` rather than adding to it. - script_seconds = round( - (earlier$script_seconds %||% installed_state$phase_seconds %||% 0) + - elapsed(script_started), - 1 - ), - started_at = installed_state$started_at %||% - earlier$started_at %||% - format(script_started, "%Y-%m-%dT%H:%M:%SZ", tz = "UTC"), - finished_at = now_utc(), - planned_minutes = shard$estimate_minutes, - planned_check_minutes = shard$check_minutes - ), - file.path(out_dir, "timing.json") -) - -# ------------------------------------------------------------------ summary -- - -results <- vapply(entries, function(e) e$result, character(1)) -df <- data.frame( - Package = vapply(entries, function(e) e$package, character(1)), - Version = vapply(entries, function(e) e$version, character(1)), - Result = results, - Old = vapply(entries, function(e) e$status_old, character(1)), - New = vapply(entries, function(e) e$status_new, character(1)), - # `baseline_reused` stopped being set when both halves became mandatory, so - # this column was empty in every row. What the baseline is still good for is - # the drift check -- whether a result from an earlier run still reproduces -- - # and that is what it says now. - Baseline = vapply( - entries, - function(e) { - if (isTRUE(e$baseline_agrees)) { - "agrees" - } else if (isFALSE(e$baseline_agrees)) { - "disagrees" - } else { - "" - } - }, - character(1) - ) -) -append_summary(c( - sprintf("### Shard %d", shard_index), - "", - sprintf( - "%d ok, %d newly broken, %d failed, %d timed out, %d depfail, %d depmissing, %d error, %d deferred.", - sum(results == "ok"), sum(results == "newly_broken"), sum(results == "failed"), - sum(results == "timeout"), - sum(results == "depfail"), sum(results == "depmissing"), - sum(results == "error"), sum(results == "deferred") - ), - "", - md_table(df) -)) -for (entry in entries) { - if (entry$result %in% c("ok", "deferred")) { - next - } - # The reason goes in the title, where `md_details()` cannot tail it away; - # the body is the check log where there is one, because the reason alone - # rarely says which check step broke. - kept <- file.path(out_dir, "pkgs", entry$package, "new-check") - log <- file.path(kept, "00check.log") - reason <- gsub("\n", " ", entry$message %||% "") - lines <- if (file.exists(log)) { - readLines(log, warn = FALSE) - } else if (nzchar(reason)) { - strsplit(entry$message, "\n")[[1]] - } else { - "(no log captured)" - } - title <- sprintf( - "%s — %s%s", - entry$package, - entry$result, - if (nzchar(reason)) paste0(": ", md_escape_html(reason)) else "" - ) - append_summary(md_details(title, lines)) - - # The check log says what broke; these say why, and none of them fits in it. - # `00install.out` is where a package that could not be installed explains - # itself -- the check log only points at the file, which used to mean - # downloading the artifact to read a compiler error. A `.Rout.fail` is a - # failed test file's whole transcript and `-Ex.Rout` the examples', where the - # check log carries a bounded excerpt. Each gets its own block and its own - # budget rather than sharing one, or the tail of the set would be all anyone - # saw. - for (extra in list( - list(file = "00install.out", what = "installation output"), - list( - file = list.files(kept, pattern = "[.]Rout[.]fail$"), - what = "test output" - ), - list( - file = list.files(kept, pattern = "-Ex[.]Rout$"), - what = "example output" - ) - )) { - for (f in extra$file) { - path <- file.path(kept, f) - if (!file.exists(path)) { - next - } - append_summary(md_details( - sprintf( - "%s — %s (%s)", - entry$package, - extra$what, - f - ), - readLines(path, warn = FALSE), - max_lines = detail_max_lines - )) - } - } -} - -inform( - "Shard ", - shard_index, - " done: ", - paste(names(table(results)), table(results), sep = "=", collapse = ", ") -) diff --git a/.github/workflows/revdepx/util.R b/.github/workflows/revdepx/util.R deleted file mode 100644 index 69d954f..0000000 --- a/.github/workflows/revdepx/util.R +++ /dev/null @@ -1,1614 +0,0 @@ -# Shared helpers for the revdepx workflow scripts (the revdep4 -# engines). -# Sourced by plan.R, build.R, shard.R and collect.R; base R plus jsonlite only, -# so every job can use it before any heavyweight dependency is installed. - -# ------------------------------------------------------------- environment -- - -`%||%` <- function(x, y) if (is.null(x)) y else x - -env_chr <- function(name, default = "") { - value <- Sys.getenv(name, unset = "") - if (identical(value, "")) default else value -} - -env_num <- function(name, default) { - value <- env_num_opt(name) - if (is.null(value)) default else value -} - -# The same, but NULL when the variable is unset or unusable -- so a caller can -# tell "not given" from "given the value that happens to be the default", which -# is what an explicit knob overriding a measurement needs to know. -env_num_opt <- function(name) { - value <- suppressWarnings(as.numeric(env_chr(name))) - if (length(value) != 1 || is.na(value)) NULL else value -} - -env_flag <- function(name) { - tolower(env_chr(name)) %in% c("1", "true", "yes") -} - -inform <- function(...) { - message(paste0(...)) -} - -now_utc <- function() { - format(Sys.time(), "%Y-%m-%dT%H:%M:%SZ", tz = "UTC") -} - -# A block of output under a heading the reader can fold away. -# -# Actions renders `::group::` as a collapsed section in the job log, so a -# hundred shard packages can each contribute their diff without any of them -# getting in the way of the summary lines between them. Outside Actions the -# markers are just two extra lines. NULL arguments are dropped, so a caller can -# pass a trailing note conditionally. -print_group <- function(title, ...) { - body <- unlist(list(...), use.names = FALSE) - message("::group::", title) - if (length(body) > 0) { - message(paste(body, collapse = "\n")) - } - message("::endgroup::") -} - -# ---------------------------------------------------------------- run ids ---- - -# GitHub run ids passed `.Machine$integer.max` in 2026, so they are carried as -# strings everywhere here: `as.integer("31048405399")` is a silent NA, and an -# NA reaching `if (run > 0)` takes the whole planning job down. "0" is the -# "no such run" sentinel the workflow's job outputs and plan.json use. -run_id_chr <- function(x) { - if (is.null(x) || length(x) != 1 || is.na(x)) "0" else trimws(as.character(x)) -} - -has_run <- function(x) { - id <- run_id_chr(x) - nzchar(id) && !identical(id, "0") -} - -# ------------------------------------------------------- links in summaries -- - -# A job summary is rendered at the run's own URL, so every link it carries has -# to be absolute; relative ones resolve against /actions/runs/ and 404. - -# A run id, linked to its page; plain text off GitHub (a local run). -run_link <- function(x) { - id <- run_id_chr(x) - if (!nzchar(gh_repo())) { - return(id) - } - sprintf( - "[%s](%s/%s/actions/runs/%s)", - id, - env_chr("GITHUB_SERVER_URL", "https://github.com"), - gh_repo(), - id - ) -} - -# This run's page, where its artifacts are. -this_run_link <- function(text = env_chr("GITHUB_RUN_ID", "local")) { - id <- run_id_chr(env_chr("GITHUB_RUN_ID")) - if (!has_run(id) || !nzchar(gh_repo())) { - return(text) - } - sprintf( - "[%s](%s/%s/actions/runs/%s)", - text, - env_chr("GITHUB_SERVER_URL", "https://github.com"), - gh_repo(), - id - ) -} - -# A revdep, linked to its CRAN page -- the one page about it that is reachable -# from a job summary, and the one that names its maintainer. -cran_link <- function(package) { - sprintf("[%s](https://cran.r-project.org/package=%s)", package, package) -} - -# ------------------------------------------------------------------- JSON ---- - -write_json <- function(x, path) { - jsonlite::write_json(x, path, auto_unbox = TRUE, digits = NA, null = "null") -} - -read_json <- function(path, simplify = FALSE) { - jsonlite::read_json(path, simplifyVector = simplify) -} - -# --------------------------------------------------------- GitHub plumbing ---- - -# Append `name=value` to the job's outputs. Values must be single-line. -set_output <- function(name, value) { - path <- Sys.getenv("GITHUB_OUTPUT") - if (nzchar(path)) { - cat(sprintf("%s=%s\n", name, value), file = path, append = TRUE) - } else { - inform("[output] ", name, "=", value) - } -} - -# Append markdown lines to the job summary, or echo them locally. -append_summary <- function(lines) { - path <- Sys.getenv("GITHUB_STEP_SUMMARY") - if (nzchar(path)) { - cat(lines, file = path, sep = "\n", append = TRUE) - cat("\n", file = path, append = TRUE) - } else { - cat(lines, sep = "\n") - cat("\n") - } -} - -# A minimal pipe table so summaries do not need knitr. -md_table <- function(df) { - esc <- function(x) gsub("|", "\\|", as.character(x), fixed = TRUE) - header <- paste0("| ", paste(esc(names(df)), collapse = " | "), " |") - rule <- paste0("|", paste(rep(" --- ", ncol(df)), collapse = "|"), "|") - rows <- vapply( - seq_len(nrow(df)), - function(i) { - paste0("| ", paste(esc(unlist(df[i, ])), collapse = " | "), " |") - }, - character(1) - ) - c(header, rule, rows) -} - -# Drop one markdown section: the first heading matching `heading`, and -# everything under it up to the next heading of any level. -drop_section <- function(lines, heading) { - at <- grep(heading, lines) - if (length(at) == 0) { - return(lines) - } - from <- at[[1]] - later <- grep("^#+[[:space:]]", lines) - later <- later[later > from] - to <- if (length(later) == 0) length(lines) else later[[1]] - 1L - lines[-seq(from, to)] -} - -# Text going into an HTML fragment of a summary (a title, say), -# where markdown's escaping does not apply. -md_escape_html <- function(x) { - x <- gsub("&", "&", x, fixed = TRUE) - x <- gsub("<", "<", x, fixed = TRUE) - gsub(">", ">", x, fixed = TRUE) -} - -# Strip ANSI escapes and carriage returns before quoting logs into markdown. -sanitize_log <- function(lines) { - lines <- gsub("\r", "", lines, fixed = TRUE) - gsub("\033\\[[0-9;?]*[a-zA-Z]", "", lines) -} - -# Fence log text so that embedded triple backticks cannot break the summary. -md_details <- function(title, lines, max_lines = 80) { - lines <- sanitize_log(lines) - omitted <- character() - if (length(lines) > max_lines) { - omitted <- sprintf( - "... (%d earlier lines omitted)", - length(lines) - max_lines - ) - lines <- utils::tail(lines, max_lines) - } - c( - sprintf("
%s", title), - "", - "````text", - omitted, - lines, - "````", - "", - "
", - "" - ) -} - -# --------------------------------------------------------- gh and artifacts -- - -# The plan resolves donor runs through the API, and the preflight and the -# shards fetch artifacts off them. Everything here is an optimization: a -# missing gh, a token without `actions: read`, an expired artifact, a network -# hiccup -- all of them have to end as "reuse nothing", never as a failure. - -gh_repo <- function() { - env_chr("GITHUB_REPOSITORY") -} - -gh_ok <- function() { - nzchar(Sys.which("gh")) && - nzchar(env_chr("GH_TOKEN", env_chr("GITHUB_TOKEN"))) -} - -# `gh`, with its arguments quoted for the shell: system2() quotes the command -# but hands the arguments to `sh` as written, and these carry `?`, `&`, `|`, -# quotes and spaces. Unquoted, an API path ends at its first `&`, and what -# follows becomes a second command the shell cannot find. -# -# Every failure becomes NULL, including the ones system2() raises instead of -# returning: a command the shell cannot run exits 127, which `stdout = TRUE` -# turns into an R error rather than a status. -gh_lines <- function(...) { - out <- tryCatch( - suppressWarnings( - system2("gh", shQuote(c(...)), stdout = TRUE, stderr = NULL) - ), - error = function(e) NULL - ) - status <- attr(out, "status") - if (is.null(out) || (!is.null(status) && status != 0)) NULL else out -} - -# The unexpired artifacts of one run, as a named character vector of ids; -# empty when the run has none or when gh cannot say. -# -# Paginated, because a single page is not enough and the ones that fall off it -# are exactly the ones that matter. A run publishes one -# `revdepx-results--` per shard -- up to 250 -- and uploads -# `revdepx-baseline`, `revdepx-timings` and `revdepx-report` last of all. Ask -# for one page of 100 and a run with a hundred shards hides its baseline behind -# its results: reuse would silently stop and `retry-run` would fail outright, -# both for a reason no log would name. -run_artifacts <- function(run_id) { - if (!gh_ok() || !nzchar(gh_repo())) { - return(character()) - } - out <- gh_lines( - "api", - "--paginate", - sprintf( - "repos/%s/actions/runs/%s/artifacts?per_page=100", - gh_repo(), - run_id - ), - "--jq", - ".artifacts[] | select(.expired == false) | [.name, .id] | @tsv" - ) - out <- out[nzchar(out)] - if (length(out) == 0) { - return(character()) - } - parts <- strsplit(out, "\t", fixed = TRUE) - stats::setNames( - vapply(parts, function(p) p[[2]], character(1)), - vapply(parts, function(p) p[[1]], character(1)) - ) -} - -# Everything below reports why it could not do its job, not only that it -# could not. These fetches are optimizations, so a failure is swallowed and -# the run continues -- which is exactly the situation where a silent one is -# expensive: a prebuilt library that does not arrive costs an hour of -# rebuilding, and the causes (an artifact that really is gone, a download -# that failed, a truncated zip, an unzip that refused it) call for entirely -# different fixes and used to look identical in the log. - -# The last few lines of what a command wrote to stderr, which is where every -# one of these tools says what went wrong. -stderr_tail <- function(path, n = 3) { - if (!file.exists(path)) { - return("") - } - lines <- tryCatch(readLines(path, warn = FALSE), error = function(e) { - character() - }) - paste(utils::tail(lines[nzchar(trimws(lines))], n), collapse = "; ") -} - -format_bytes <- function(n) { - if (!is.finite(n) || n < 0) { - return("unknown size") - } - if (n >= 1024^3) { - sprintf("%.2f GiB", n / 1024^3) - } else if (n >= 1024^2) { - sprintf("%.1f MiB", n / 1024^2) - } else { - sprintf("%.0f B", n) - } -} - -# utils::unzip() refuses archives above 4 GB, which a library artifact reaches -# without trying; the system unzip has no such limit, so prefer it and keep -# the internal one for a runner without it. -# -# Returns TRUE, or a string saying why not -- both are truthy in R, so callers -# must test with isTRUE(). -unzip_into <- function(zip, dest) { - dir.create(dest, recursive = TRUE, showWarnings = FALSE) - if (nzchar(Sys.which("unzip"))) { - err <- tempfile(fileext = ".err") - on.exit(unlink(err), add = TRUE) - status <- tryCatch( - suppressWarnings( - # Quoted: system2() quotes the command, but not the arguments. - system2( - "unzip", - shQuote(c("-q", "-o", zip, "-d", dest)), - stdout = NULL, - stderr = err - ) - ), - error = function(e) 1L - ) - if (identical(as.integer(status), 0L)) { - return(TRUE) - } - detail <- stderr_tail(err) - return(sprintf( - "unzip exited %d%s", - as.integer(status), - if (nzchar(detail)) paste0(": ", detail) else "" - )) - } - # A gh that wrote an error body instead of the artifact leaves something - # that is not a zip; that is a missing artifact, not a usable one. - extracted <- tryCatch( - suppressWarnings(utils::unzip(zip, exdir = dest)), - error = function(e) character() - ) - if (length(extracted) > 0) TRUE else "utils::unzip() extracted nothing" -} - -# Download one artifact by id into a directory; NULL when it cannot be had, -# with a line in the log saying which of the ways it failed. -fetch_artifact_id <- function(id, dest, what = paste("artifact", id)) { - if (!gh_ok() || !nzchar(gh_repo())) { - inform(what, ": not fetched (no gh, or no token with `actions: read`)") - return(NULL) - } - zip <- tempfile(fileext = ".zip") - on.exit(unlink(zip), add = TRUE) - err <- tempfile(fileext = ".err") - on.exit(unlink(err), add = TRUE) - started <- Sys.time() - # Quoted: system2() quotes the command, but not the arguments. - args <- shQuote(c("api", sprintf("repos/%s/actions/artifacts/%s/zip", gh_repo(), id))) - status <- tryCatch( - suppressWarnings(system2("gh", args, stdout = zip, stderr = err)), - error = function(e) 1L - ) - elapsed <- as.numeric(difftime(Sys.time(), started, units = "secs")) - bytes <- if (file.exists(zip)) file.size(zip) else 0 - if (!identical(as.integer(status), 0L) || bytes == 0) { - detail <- stderr_tail(err) - inform(sprintf( - "%s: download failed after %.0f s -- gh exited %d, %s written%s", - what, - elapsed, - as.integer(status), - format_bytes(bytes), - if (nzchar(detail)) paste0(": ", detail) else "" - )) - return(NULL) - } - inform(sprintf( - "%s: downloaded %s in %.0f s (%.0f MB/s)", - what, - format_bytes(bytes), - elapsed, - bytes / 1e6 / max(elapsed, 1) - )) - unpacked <- unzip_into(zip, dest) - if (!isTRUE(unpacked)) { - inform(what, ": the download is not usable -- ", unpacked) - return(NULL) - } - dest -} - -# Fetch one named artifact of one run; NULL when the run does not have it, it -# has expired, or it cannot be downloaded -- and the log says which. -fetch_artifact <- function(run_id, name, dest) { - ids <- run_artifacts(run_id) - id <- unname(ids[names(ids) == name]) - what <- sprintf("%s of run %s", name, run_id) - if (length(id) == 0) { - inform( - what, - ": the run has no unexpired artifact by that name", - if (length(ids) > 0) { - paste0(" (it has: ", paste(sort(names(ids)), collapse = ", "), ")") - } else { - " (and none at all, or gh could not list them)" - } - ) - return(NULL) - } - fetch_artifact_id(id[[1]], dest, what) -} - -# ------------------------------------------------------- library helpers ---- - -# revdep2 carried a whole installed library between runs as a tar artifact, -# with pack/unpack/restore machinery to match. The universe *image* replaced -# all of that: the library travels inside the image, and reuse is a registry -# pull. What survives is the one helper that never cared where a library came -# from. - -# The packages of `lib` a caller may still want: everything not already -# installed there, and nothing this session has loaded -- a package must never -# be overwritten underneath the driver that is using it. -missing_from <- function(lib, wanted) { - setdiff( - unique(unlist(wanted, use.names = FALSE)), - c(list.dirs(lib, full.names = FALSE, recursive = FALSE), loadedNamespaces()) - ) -} - -# ------------------------------------------------------- the last report ---- - -# The packages an earlier report says were not ok, read from the `revdep/` -# directory in the checkout rather than from a run's artifacts. -# -# That directory is the durable record: the collector commits it back to the -# checked branch, and before this workflow existed `revdepcheck::cloud_check()` -# wrote the same four files there. So both generations are read: `manifest.json` -# when this workflow wrote it (it says exactly which result each package got), -# and otherwise revdepcheck's own markdown -- one `# ()` -# heading per package in problems.md and failures.md, plus the "Failed to -# check" table in README.md, which is where a package that produced no -# comparison at all is named. -report_packages <- function(dir) { - none <- list(packages = character(), source = "") - if (!nzchar(dir %||% "") || !dir.exists(dir)) { - return(none) - } - manifest <- file.path(dir, "manifest.json") - if (file.exists(manifest)) { - entries <- tryCatch(read_json(manifest), error = function(e) NULL) - if (length(entries) > 0) { - names <- vapply(entries, function(e) e$package %||% "", character(1)) - results <- vapply(entries, function(e) e$result %||% "", character(1)) - take <- nzchar(names) & vapply(results, needs_recheck, logical(1)) - if (any(take)) { - return(list( - packages = sort(unique(names[take])), - source = "manifest.json" - )) - } - } - } - headings <- function(file) { - if (!file.exists(file)) { - return(character()) - } - lines <- grep("^# ", readLines(file, warn = FALSE), value = TRUE) - trimws(sub("^# ([^ (]+).*$", "\\1", lines)) - } - failed_table <- function(file) { - if (!file.exists(file)) { - return(character()) - } - lines <- readLines(file, warn = FALSE) - from <- grep("^#+ +Failed to check", lines) - if (length(from) == 0) { - return(character()) - } - after <- grep("^#+ ", lines) - after <- after[after > from[[1]]] - block <- lines[seq( - from[[1]], - if (length(after) > 0) after[[1]] - 1L else length(lines) - )] - cells <- trimws(sub( - "^\\|([^|]*)\\|.*$", - "\\1", - grep("^\\|", block, value = TRUE) - )) - # Drop the header and the alignment row; what is left is one package each. - cells[nzchar(cells) & cells != "package" & !grepl("^:?-+:?$", cells)] - } - packages <- unique(c( - headings(file.path(dir, "problems.md")), - headings(file.path(dir, "failures.md")), - failed_table(file.path(dir, "README.md")) - )) - list( - packages = sort(packages[nzchar(packages)]), - source = "problems.md, failures.md, README.md" - ) -} - -# --------------------------------------------------------- measured timings -- - -# What a run measured about itself, so the next plan can stop guessing. -# -# The collector writes one `timings.json` per run -- a row per package (how -# long its checks actually took here, next to what CRAN reports for it) and a -# row per shard (job, install and check minutes, next to what the plan -# predicted) -- and publishes it as the small `revdepx-timings` artifact, -# separate from the report so -# a plan reads it without downloading anything else. Runs of either revdepx -# workflow publish and consume the same artifact: measured seconds are -# canonical per-half wall clock, so the two engines share one pool. -# -# Three constants come out of it, each a median over what actually happened, -# and each NULL when the runs measured nothing usable -- the caller keeps its -# default then. `runs` is youngest first; a younger measurement of a package -# wins, and the constants pool every row there is. - -read_timings <- function(dir) { - if (!nzchar(dir %||% "")) { - return(NULL) - } - path <- file.path(dir, "timings.json") - if (!file.exists(path)) { - return(NULL) - } - tryCatch(read_json(path), error = function(e) NULL) -} - -# Seconds one check of a package took here, per package, youngest run first. -measured_check_seconds <- function(runs) { - out <- stats::setNames(numeric(), character()) - for (run in runs) { - for (row in run$packages %||% list()) { - seconds <- suppressWarnings(as.numeric(row$seconds %||% NA)) - if ( - is.null(row$package) || - row$package %in% names(out) || - is.na(seconds) || - seconds <= 0 - ) { - next - } - out[[row$package]] <- seconds - } - } - out -} - -# The medians the plan's cost model runs on: -# check_scale - check seconds here per second CRAN reports (T_total); -# these runners are not CRAN's machines -# setup_minutes - per-shard fixed cost, from job start to the driver's -# first line: the runner image, R, the artifacts, and now -# the universe image pull -# install_seconds - marginal cost of one more dependency in a shard's union -# (zero on the image path; measured only when a shard fell -# back to building its own) -# -# `engine` filters the *shard* rows: the per-package `seconds` is canonical -# per-half wall clock in both engines and pools freely, but a shard's setup -# and install minutes are shaped by how that engine provisions and runs its -# checks, so only same-engine runs may vote on those. Runs written before the -# field existed carry no `engine` and are excluded from the shard medians. -calibration <- function(runs, engine = NULL) { - scales <- numeric() - setups <- numeric() - installs <- numeric() - packages <- 0L - shards <- 0L - for (run in runs) { - for (row in run$packages %||% list()) { - seconds <- suppressWarnings(as.numeric(row$seconds %||% NA)) - cran <- suppressWarnings(as.numeric(row$t_total %||% NA)) - packages <- packages + 1L - if (!is.na(seconds) && !is.na(cran) && seconds > 0 && cran > 0) { - scales <- c(scales, seconds / cran) - } - } - if (!is.null(engine) && !identical(run$engine %||% "", engine)) { - next - } - for (row in run$shards %||% list()) { - shards <- shards + 1L - job <- suppressWarnings(as.numeric(row$job_minutes %||% NA)) - script <- suppressWarnings(as.numeric(row$script_minutes %||% NA)) - if (!is.na(job) && !is.na(script) && job >= script) { - setups <- c(setups, job - script) - } - minutes <- suppressWarnings(as.numeric(row$install_minutes %||% NA)) - count <- suppressWarnings(as.numeric(row$install_packages %||% NA)) - if (!is.na(minutes) && !is.na(count) && count > 0) { - installs <- c(installs, minutes * 60 / count) - } - } - } - median_or_null <- function(x) { - if (length(x) == 0) NULL else unname(stats::median(x)) - } - list( - check_scale = median_or_null(scales), - setup_minutes = median_or_null(setups), - install_seconds = median_or_null(installs), - packages = packages, - shards = shards, - runs = length(runs) - ) -} - -# How long each shard's *job* took, by shard index. The driver can time itself, -# but not the minutes before it starts -- the runner image, R, pandoc, TinyTeX, -# the artifact downloads. That gap is exactly the per-shard setup cost the plan -# charges for every extra shard, so it is measured here, in the one job that -# runs after all the shards and can still ask the API about them. -run_shard_job_minutes <- function(run_id) { - empty <- stats::setNames(numeric(), character()) - if (!gh_ok() || !nzchar(gh_repo())) { - return(empty) - } - rows <- character() - for (page in 1:5) { - got <- gh_lines( - "api", - sprintf( - "repos/%s/actions/runs/%s/jobs?per_page=100&page=%d", - gh_repo(), - run_id, - page - ), - "--jq", - ".jobs[] | [.name, .started_at, .completed_at] | @tsv" - ) - got <- if (is.null(got)) character() else got[nzchar(got)] - rows <- c(rows, got) - if (length(got) < 100) { - break - } - } - out <- empty - stamp <- function(x) { - as.POSIXct(x, format = "%Y-%m-%dT%H:%M:%SZ", tz = "UTC") - } - for (row in rows) { - fields <- strsplit(row, "\t", fixed = TRUE)[[1]] - if (length(fields) < 3 || !grepl("^shard [0-9]+ ", fields[[1]])) { - next - } - index <- sub("^shard ([0-9]+) .*$", "\\1", fields[[1]]) - from <- stamp(fields[[2]]) - to <- stamp(fields[[3]]) - if (is.na(from) || is.na(to) || to < from) { - next - } - minutes <- as.numeric(difftime(to, from, units = "mins")) - # A re-run reports the later attempt last; it is the one that produced the - # artifact the collector is reading. - out[[index]] <- minutes - } - out -} - -# ------------------------------------------------------------ CRAN metadata -- - -cran_repo <- function() { - env_chr("REVDEPX_CRAN_MIRROR", "https://cloud.r-project.org") -} - -# available.packages() for the canonical CRAN mirror, fetched once. -cran_db <- local({ - db <- NULL - function() { - if (is.null(db)) { - inform("Fetching CRAN package metadata from ", cran_repo()) - # No R_version filter on purpose (the containers may run a newer R - # than this script), but OS_type must apply: run 33777134786 planned - # hespdiv, an `OS_type: windows` package, three runs in a row -- the - # shard's download.packages(), which does filter by OS, then refused - # it each time ("no package 'hespdiv' at the repositories"), and the - # report carried a permanent phantom error for a package Linux can - # never check. - db <<- utils::available.packages( - repos = cran_repo(), - filters = c("CRAN", "duplicates", "OS_type") - ) - } - db - } -}) - -base_packages <- function() { - rownames(utils::installed.packages(priority = c("base", "recommended"))) -} - -# The Bioconductor repositories matching the running R version -- software, -# annotation, experiment and workflows, the four that hold packages a -# dependency field can name. The version mapping is the one -# setRepositories() itself uses (in utils since R 4.5, in tools before -# that); it is an internal, so an R that keeps it somewhere else degrades to -# "no Bioconductor metadata" rather than an error. R_BIOC_VERSION overrides -# the mapping, as it does for base R. -bioc_repos <- function() { - mapping <- function(ns) { - as.character(get( - ".BioC_version_associated_with_R_version", - envir = getNamespace(ns) - )()) - } - version <- tryCatch( - mapping("utils"), - error = function(e) tryCatch(mapping("tools"), error = function(e) NA) - ) - if (is.na(version) || !nzchar(version)) { - return(character()) - } - mirror <- env_chr("REVDEPX_BIOC_MIRROR", "https://bioconductor.org") - c( - BioCsoft = sprintf("%s/packages/%s/bioc", mirror, version), - BioCann = sprintf("%s/packages/%s/data/annotation", mirror, version), - BioCexp = sprintf("%s/packages/%s/data/experiment", mirror, version), - BioCworkflows = sprintf("%s/packages/%s/workflows", mirror, version) - ) -} - -# cran_db() plus the Bioconductor repositories: the metadata to resolve -# *dependencies* against, as opposed to the metadata that decides what is a -# CRAN reverse dependency. In run 32158907637, 121 packages came back -# `depmissing` on Bioconductor dependencies (DESeq2, pwalign, ...) that pak -# would have installed happily -- pinned_repos() has carried the Bioconductor -# repositories all along -- but install_closure() intersected every -# dependency list with CRAN's rownames, so the planner dropped the names -# before pak ever saw them. Enumeration stays on cran_db(): the packages -# *checked* are CRAN's reverse dependencies, and this db only widens what -# they may depend on. -# -# On a Bioconductor fetch failure the CRAN half still serves, degraded to -# exactly the old behaviour; the pinned install repositories are resolved -# independently by pak, so a blip here cannot skew an install, only thin a -# closure. -dep_db <- local({ - db <- NULL - function() { - if (is.null(db)) { - cran <- cran_db() - repos <- bioc_repos() - bioc <- if (length(repos) == 0) { - NULL - } else { - inform( - "Fetching Bioconductor package metadata (", - paste(names(repos), collapse = ", "), - ")" - ) - tryCatch( - utils::available.packages(repos = repos, filters = "duplicates"), - error = function(e) { - inform( - "Could not fetch Bioconductor metadata: ", - conditionMessage(e) - ) - NULL - } - ) - } - if (is.null(bioc) || nrow(bioc) == 0) { - db <<- cran - } else { - merged <- rbind(cran, bioc[, colnames(cran), drop = FALSE]) - merged <- merged[!duplicated(rownames(merged)), , drop = FALSE] - inform( - "Dependency metadata: ", - nrow(cran), - " CRAN + ", - nrow(merged) - nrow(cran), - " Bioconductor packages" - ) - db <<- merged - } - } - db - } -}) - -# The packages that must be installed to check `packages`: their hard -# dependencies and direct suggests, plus the recursive hard dependencies of -# all of those. One list per element of `packages`. -install_closure <- function(packages, db) { - direct <- tools::package_dependencies(packages, db = db, which = "most") - pool <- unique(unlist(direct, use.names = FALSE)) - pool <- intersect(pool, rownames(db)) - recursive <- tools::package_dependencies( - pool, - db = db, - which = "strong", - recursive = TRUE - ) - lapply(direct, function(deps) { - deps <- intersect(deps, rownames(db)) - full <- unique(c(deps, unlist(recursive[deps], use.names = FALSE))) - sort(setdiff(intersect(full, rownames(db)), base_packages())) - }) -} - -# The same set, cut into installable pieces: chunks of at most `size`, -# ordered so that every strong dependency inside the set is installed before -# the package that needs it. -# -# One pak call for a few thousand refs means one resolution of a few thousand -# refs, and that is where the preflight of run 31270092803 died: ten minutes -# inside pak, not one install started, then the runner was shut down. The -# resolution is the part that does not degrade gracefully, so it is the part -# that is kept small -- each chunk resolves against a library where its -# dependencies already are. -# -# It also changes what a failure costs. Whatever earlier chunks installed is -# on disk and is skipped on the next attempt, so a chunk that dies costs a -# chunk; and the log says which one, which a single opaque call never could. -# -# The size is a trade, and 100 was too far towards small. A chunk pays one -# resolution whether or not it installs anything: run 31930350338's preflight -# logged `80 pkgs + 214 deps: kept 294 [44s]` for a chunk that built nothing at -# all. At 100, the 4406-package universe is 45 chunks and something like half -# an hour of resolution before a single build starts -- on the critical path, -# since every shard waits for the preflight. At 400 it is 12 chunks. A chunk -# that dies costs four times as much to redo, which is the price; the counter -# is that the resolution which killed run 31270092803 was a few thousand refs, -# and 400 is an order of magnitude below that. -# -# Ordering is on strong dependencies only. Suggests are in the set because a -# revdep's *check* needs them, not its installation, and they are what makes -# the graph cyclic -- ordering on them would order on nothing. -install_chunks <- function(pkgs, db, size = 400) { - pkgs <- unique(pkgs) - if (length(pkgs) == 0) { - return(list()) - } - deps <- tools::package_dependencies(pkgs, db = db, which = "strong") - index <- stats::setNames(seq_along(pkgs), pkgs) - needs <- lapply(pkgs, function(p) { - unname(index[intersect(deps[[p]] %||% character(), pkgs)]) - }) - done <- logical(length(pkgs)) - order <- integer() - repeat { - ready <- which(!done & vapply(needs, function(d) all(done[d]), logical(1))) - if (length(ready) == 0) { - break - } - done[ready] <- TRUE - order <- c(order, ready) - } - # A cycle, or a dependency this index cannot describe, leaves packages that - # never become ready. They go last, together, for pak to sort out among - # themselves -- which is what it was doing for the whole set before. - order <- c(order, which(!done)) - unname(split(pkgs[order], ceiling(seq_along(order) / size))) -} - -# Install one chunked set, reporting each chunk as it lands. Returns TRUE when -# every chunk succeeded; a caller that cares which packages are missing asks -# the library, not this. -# Run `fun` in a child R process and give up on it after `timeout_seconds`. -# -# Nothing this workflow calls out to has a time limit of its own, and in run -# 31276552027 that cost a job: `pak::pkg_install()` on chunk 21 never returned, -# and the preflight sat at one busy core and flat memory for 76 minutes until -# it was cancelled by hand. There is no loop to break there -- the call simply -# does not come back -- so the only thing that helps is a clock. -# -# Two details make this work where `tryCatch` and `setTimeLimit` do not. The -# child inherits this process's stdout and stderr, so pak's progress still -# streams to the job log with nobody draining a pipe; and it is killed with -# `kill_tree()`, because what wedges is pak's *own* subprocess, a grandchild, -# which outlives a plain kill of its parent. -# -# It also isolates the calls from each other. Chunks 14 and 20 of that run had -# already failed with "error in pak subprocess" before 21 hung, and one wedged -# pak subprocess used to poison every call after it; now each one starts a -# fresh R and a fresh pak. -# -# callr comes with rcmdcheck, and the preflight installs it outright. Where it -# is missing there is no way to bound anything, so the call is made inline -- -# the old behaviour, announced rather than silent. -run_with_timeout <- function(fun, args = list(), timeout_seconds, label = "") { - if (!requireNamespace("callr", quietly = TRUE)) { - inform(label, ": callr is not installed, running without a time limit") - value <- NULL - message <- tryCatch( - { - value <- do.call(fun, args) - "" - }, - error = function(e) conditionMessage(e) - ) - return(list( - ok = !nzchar(message), - timed_out = FALSE, - message = message, - value = value - )) - } - process <- callr::r_bg( - fun, - args = args, - stdout = "", - stderr = "2>&1", - supervise = TRUE - ) - process$wait(timeout = timeout_seconds * 1000) - if (process$is_alive()) { - process$kill_tree() - process$wait(timeout = 10000) - return(list( - ok = FALSE, - timed_out = TRUE, - message = sprintf( - "no output and no result after %s; killed", - format_duration(timeout_seconds) - ) - )) - } - value <- NULL - message <- tryCatch( - { - value <- process$get_result() - "" - }, - # callr reports a child's failure wrapped in its own condition, and the - # wrapper is three lines of scaffolding around the one line that says what - # broke -- which is the line that ends up in depfail.json. - error = function(e) conditionMessage(e$parent %||% e) - ) - list( - ok = !nzchar(message), - timed_out = FALSE, - message = message, - value = value - ) -} - -format_duration <- function(seconds) { - if (seconds < 90) { - sprintf("%.0f s", seconds) - } else if (seconds < 90 * 60) { - sprintf("%.0f min", seconds / 60) - } else { - # A shard runs for hours, and "217 min left" is a number the reader has to - # divide before it means anything. - sprintf("%.1f h", seconds / 3600) - } -} - -# ---------------------------------------------------- pak's repositories ---- - -# The repository set, resolved once and pinned. -# -# pak reads `getOption("repos")` and adds the Bioconductor repositories to it -# the moment something needs them -- and its metadata database is keyed on the -# set. In run 31282820357 the first Bioconductor package landed in chunk 11 of -# 45; the set went from 1 repository to 6 and the database from 7 files to 9, -# the rebuilt database came back empty ("0 B in 9 files", parsed in 20 ms -# rather than 9 s), and from chunk 12 on pak could not find a single package -# on CRAN. Not vctrs -- all 4406 of them. -# -# Installing in chunks is what made that reachable: 45 short-lived pak -# processes each re-read the database from disk, so the set changing under one -# of them poisons all the rest. Resolving the set here, before the first -# install, is what stops it from changing at all. -pinned_repos <- local({ - repos <- NULL - function() { - if (is.null(repos)) { - repos <<- tryCatch( - { - got <- pak::repo_get(bioc = TRUE) - stats::setNames(got$url, got$name) - }, - error = function(e) { - inform("Could not resolve the repository set: ", conditionMessage(e)) - getOption("repos") - } - ) - inform( - "Repositories pinned: ", - length(repos), - " (", - paste(names(repos), collapse = ", "), - ")" - ) - } - repos - } -}) - -# ------------------------------------------------------ pak's metadata db ---- - -# Packages that must be in any CRAN snapshot. If pak cannot see these, it -# cannot see anything, and what follows is not a dependency problem. -metadata_probe <- function() { - strsplit(env_chr("REVDEPX_METADATA_PROBE", "vctrs,cli,R6"), ",")[[1]] -} - -metadata_timeout_seconds <- function() { - env_num("REVDEPX_METADATA_TIMEOUT_MINUTES", 10) * 60 -} - -# How many of those packages pak can actually see, or -1 when it could not be -# asked. `meta_list()` is the low-level view of the database itself, so a -# broken one answers immediately instead of being reported as a dependency -# that cannot be solved. -# -# It has to run in a fresh process. pak keeps the parsed database in the -# memory of its own subprocess, so a session that already loaded a good one -# goes on reporting health that is no longer on disk -- which is why the break -# in that run only surfaced at the *next* chunk. -metadata_found <- function() { - run <- run_with_timeout( - function(repos, probe) { - options(repos = repos) - nrow(pak::meta_list(pkg = probe)) - }, - args = list(repos = pinned_repos(), probe = metadata_probe()), - timeout_seconds = metadata_timeout_seconds(), - label = "pak metadata probe" - ) - if (!isTRUE(run$ok)) { - # Not the same thing as an empty database, and saying so matters: in run - # 31303054725 `/tmp` filled, callr could no longer start R, and every - # probe from then on failed to run at all -- reported as "pak sees 0 of - # 3", which reads like the database being empty and is a completely - # different problem. - inform("Could not ask pak what it can see: ", run$message) - return(-1L) - } - as.integer(run$value %||% 0L) -} - -# Delete the metadata database and fetch it again. -# -# `meta_clean(force = TRUE)` is the part that matters. pak's own repair -- -# `meta_update()` alone -- is what produced "0 B in 9 files": it re-validated -# the broken files, found them unchanged, and left the empty database in -# place. Only deleting it first gets a good one back. -metadata_repair <- function() { - run_with_timeout( - function(repos) { - options(repos = repos) - pak::meta_clean(force = TRUE) - pak::meta_update() - invisible(NULL) - }, - args = list(repos = pinned_repos()), - timeout_seconds = metadata_timeout_seconds(), - label = "pak metadata rebuild" - ) -} - -# Assess pak's metadata database, and rebuild it at most once per job. -# -# Returns "ok", "repaired" or "broken". Once per job is deliberate: a database -# that is still empty after a clean rebuild is not a stale cache, and clearing -# it in a loop would spend the job's minutes hiding that. -ensure_metadata <- local({ - repaired <- FALSE - function(where = "") { - prefix <- if (nzchar(where)) paste0(where, ": ") else "" - wanted <- length(metadata_probe()) - found <- metadata_found() - if (found >= wanted) { - return("ok") - } - # A probe that could not be run says nothing about the database, and - # clearing it on that evidence would spend the one rebuild on a machine - # problem -- which is exactly what would have happened when R could no - # longer start. - if (found < 0) { - inform(prefix, "the state of the metadata database is unknown") - return("unknown") - } - if (repaired) { - inform(sprintf( - "%spak still sees %d of %d probe packages after a rebuild; not clearing again", - prefix, - max(found, 0L), - wanted - )) - return("broken") - } - repaired <<- TRUE - inform(sprintf( - "%spak sees %d of %d packages that must exist -- its metadata database is unusable; clearing and rebuilding it once", - prefix, - max(found, 0L), - wanted - )) - rebuild <- metadata_repair() - if (!isTRUE(rebuild$ok)) { - inform(prefix, "the rebuild failed: ", rebuild$message) - return("broken") - } - found <- metadata_found() - if (found >= wanted) { - inform(prefix, "the metadata database is usable again") - return("repaired") - } - inform(sprintf( - "%sstill %d of %d after the rebuild; the repositories themselves are not answering", - prefix, - max(found, 0L), - wanted - )) - "broken" - } -}) - -# -------------------------------------------------- system requirements ---- - -sysreqs_timeout_seconds <- function() { - env_num("REVDEPX_SYSREQS_TIMEOUT_MINUTES", 20) * 60 -} - -# The system requirements of packages that were unpacked rather than installed. -# -# pak installs system requirements for the packages *it* installs. Everything -# restored from a library tarball -- this run's preflight library, an earlier -# run's donor -- it never sees, so their apt packages are never resolved: 170 -# of a shard's 436 dependencies arrived that way in run 31282820357. It -# usually survives, because something else pulls the same apt package in or -# the runner image already carries it; when it does not, a restored binary -# cannot load its shared library, and a shard has no load test to catch that. -# -# So the library is asked directly rather than the install list, which is what -# makes this cover donor libraries from earlier runs too -- their apt state was -# never recorded anywhere, and pak can still read what they left behind. -# -# `sysreqs_check_installed()` says what is missing and which packages want it, -# which is worth printing either way; `sysreqs_fix_installed()` installs it. -ensure_sysreqs <- function(lib = NULL, label = "") { - prefix <- if (nzchar(label)) paste0(label, ": ") else "" - - survey <- function(what) { - run <- run_with_timeout( - function(repos, lib) { - options(repos = repos) - got <- pak::sysreqs_check_installed(library = lib) - absent <- !got$installed - list( - total = nrow(got), - missing = as.character(got$system_package[absent]), - wanted_by = vapply( - got$packages[absent], - function(p) paste(p, collapse = ", "), - character(1) - ) - ) - }, - args = list(repos = pinned_repos(), lib = lib), - timeout_seconds = sysreqs_timeout_seconds(), - label = paste0(prefix, what) - ) - if (!isTRUE(run$ok)) { - inform(prefix, "could not check system requirements: ", run$message) - return(NULL) - } - run$value - } - - before <- survey("system requirements survey") - if (is.null(before)) { - return(invisible(NULL)) - } - if (length(before$missing) == 0) { - inform(sprintf( - "%sall %d system requirement(s) of the installed library are present", - prefix, - before$total - )) - return(invisible(character())) - } - inform(sprintf( - "%s%d of %d system requirement(s) are missing: %s", - prefix, - length(before$missing), - before$total, - paste( - sprintf("%s (%s)", before$missing, before$wanted_by), - collapse = "; " - ) - )) - - fixed <- run_with_timeout( - function(repos, lib) { - options(repos = repos) - pak::sysreqs_fix_installed(library = lib) - invisible(NULL) - }, - args = list(repos = pinned_repos(), lib = lib), - timeout_seconds = sysreqs_timeout_seconds(), - label = paste0(prefix, "system requirements install") - ) - if (!isTRUE(fixed$ok)) { - inform(prefix, "installing them failed: ", fixed$message) - return(invisible(before$missing)) - } - - after <- survey("system requirements re-survey") - still <- if (is.null(after)) before$missing else after$missing - if (length(still) == 0) { - inform(sprintf( - "%sinstalled %d missing system package(s)", - prefix, - length(before$missing) - )) - } else { - inform(sprintf( - "%s%d system package(s) are still missing: %s", - prefix, - length(still), - paste(still, collapse = ", ") - )) - } - invisible(still) -} - -# The system requirements of the packages the shard is about to *check*. -# -# `ensure_sysreqs()` above reads the installed library, which is the right -# question for dependencies and the wrong one for the revdeps themselves: a -# shard installs each package's dependency closure and never the package, so -# the revdep under test is never in that library. `R CMD check` builds it from -# its tarball, and nothing has resolved its `SystemRequirements` -- not -# `PKG_SYSREQS`, which covers what pak installs, and not -# `sysreqs_fix_installed()`, which covers what is on disk. -# -# Libra is the case that found this. It declares `SystemRequirements: gsl`, -# `pak::pkg_sysreqs("Libra")` resolves it to `libgsl0-dev` without difficulty, -# and nobody asked: the check failed to compile `LBLasso.c` under both versions -# with `fatal error: gsl/gsl_vector.h: No such file or directory`, which the -# report then recorded as a package that fails to install rather than as a -# runner that could not build it. -# -# Only the missing ones are installed. `sysreqs_list_system_packages()` says -# what is already there, including what other packages *provide* -- a virtual -# package satisfies a dependency just as a real one does -- so a shard whose -# requirements the image already carries runs no apt at all. -ensure_check_sysreqs <- function(packages, label = "") { - prefix <- if (nzchar(label)) paste0(label, ": ") else "" - if (length(packages) == 0) { - return(invisible(character())) - } - - run <- run_with_timeout( - function(repos, packages) { - options(repos = repos) - # In chunks: one pak::pkg_sysreqs() call over 3435 packages grew past - # 14 GB and was OOM-killed (run 32114635495) -- pak solves the whole - # set in one subprocess. Per-chunk calls each get a fresh, bounded - # subprocess, and the union of apt packages is the same. - wanted <- character() - for (part in split( - packages, - ceiling(seq_along(packages) / 300) - )) { - # Per-chunk tolerance: one chunk pak cannot solve (a package gone - # from the repositories, a resolution hiccup) must not cost the - # other chunks' system packages -- run 32148999976 lost the whole - # survey to a single subprocess error. - wanted <- unique(c( - wanted, - tryCatch( - unlist( - pak::pkg_sysreqs(part)$packages$system_packages, - use.names = FALSE - ), - error = function(e) { - message( - "sysreqs survey chunk failed (", - conditionMessage(e), - "); continuing with the other chunks" - ) - character() - } - ) - )) - } - have <- pak::sysreqs_list_system_packages() - present <- unique(c( - have$package, - unlist(have$provides, use.names = FALSE) - )) - list(wanted = wanted, missing = setdiff(wanted, present)) - }, - args = list(repos = pinned_repos(), packages = packages), - timeout_seconds = sysreqs_timeout_seconds(), - label = paste0(prefix, "check system requirements survey") - ) - if (!isTRUE(run$ok)) { - inform( - prefix, - "could not resolve the checked packages' system requirements: ", - run$message - ) - return(invisible(NULL)) - } - - if (length(run$value$missing) == 0) { - inform(sprintf( - "%sall %d system requirement(s) of the %d package(s) to check are present", - prefix, - length(run$value$wanted), - length(packages) - )) - return(invisible(character())) - } - inform(sprintf( - "%s%d of %d system requirement(s) of the packages to check are missing: %s", - prefix, - length(run$value$missing), - length(run$value$wanted), - paste(run$value$missing, collapse = ", ") - )) - - # apt directly rather than through pak: `sysreqs_fix_installed()` reads the - # library, and these packages are not in it. Failure is reported and not - # fatal -- the check will fail either way, and it will say why more clearly - # than this can. - # - # `update` first, always: the base image deletes /var/lib/apt/lists after - # its own installs (as images do), and pak only refreshes them when it - # installs a sysreq of its own in the same container. Without this, every - # install below dies with "Unable to locate package" -- warned, non-fatal, - # and exactly the silent gap this function exists to close. - sudo <- if (identical(Sys.info()[["effective_user"]], "root")) { - character() - } else { - "sudo" - } - update_status <- suppressWarnings(system2( - if (length(sudo)) "sudo" else "apt-get", - c( - if (length(sudo)) "apt-get", - "-o", - "DPkg::Lock::Timeout=300", - "update" - ) - )) - if (!identical(update_status, 0L)) { - inform(prefix, "apt-get update exited ", update_status, "; trying anyway") - } - status <- suppressWarnings(system2( - if (length(sudo)) "sudo" else "apt-get", - c( - if (length(sudo)) "apt-get", - "-o", - "DPkg::Lock::Timeout=300", - "install", - "-y", - "--no-install-recommends", - run$value$missing - ) - )) - if (!identical(status, 0L)) { - inform(prefix, "apt-get exited ", status, "; the checks run anyway") - return(invisible(run$value$missing)) - } - inform(sprintf( - "%sinstalled %d system package(s) for the packages to check", - prefix, - length(run$value$missing) - )) - invisible(character()) -} - -# One pak install, bounded. Separate from install_in_chunks() because the -# per-package retry after a failed chunk needs exactly the same treatment: it -# is the same call, one package at a time, and it used to be just as -# unbounded. -pak_install <- function( - pkgs, - lib = NULL, - upgrade = FALSE, - timeout_seconds, - label = "" -) { - run_with_timeout( - function(pkgs, lib, upgrade, repos) { - # The pin travels into every child: an option set in the parent is not - # inherited, and a child that resolves its own repository set is a child - # that can change it. - options(repos = repos) - if (is.null(lib)) { - pak::pkg_install(pkgs, ask = FALSE, upgrade = upgrade) - } else { - pak::pkg_install(pkgs, lib = lib, ask = FALSE, upgrade = upgrade) - } - invisible(NULL) - }, - args = list( - pkgs = pkgs, - lib = lib, - upgrade = upgrade, - repos = pinned_repos() - ), - timeout_seconds = timeout_seconds, - label = label - ) -} - -# `deadline` is the wall clock past which no further chunk is started. It is -# not a second timeout but the answer to a different question: the per-chunk -# limit stops one call from running for ever, and this stops 45 of them from -# adding up past what the job has. What is left unattempted is named, and the -# caller still gets to pack and publish what did install. -install_in_chunks <- function( - chunks, - lib = NULL, - upgrade = FALSE, - label = "", - timeout_seconds = install_timeout_seconds(), - deadline = NULL -) { - ok <- TRUE - prefix <- if (nzchar(label)) paste0(label, ": ") else "" - for (i in seq_along(chunks)) { - if (!is.null(deadline) && Sys.time() > deadline) { - inform(sprintf( - "%sthe install deadline passed; %d of %d chunk(s) not attempted", - prefix, - length(chunks) - i + 1L, - length(chunks) - )) - return(FALSE) - } - started <- Sys.time() - label <- sprintf("%schunk %d/%d", prefix, i, length(chunks)) - run <- pak_install( - chunks[[i]], - lib = lib, - upgrade = upgrade, - timeout_seconds = timeout_seconds, - label = label - ) - # A chunk that fails may have failed because pak could not see the - # repositories at all, which is a different thing from a package that will - # not install -- and it is the state that, left alone, fails every chunk - # after it too. Only a rebuild that actually changed something earns the - # retry; a healthy database means the failure was real. - if ( - !run$ok && identical(ensure_metadata(sub(": $", "", prefix)), "repaired") - ) { - inform(prefix, "retrying chunk ", i, " against the rebuilt metadata") - run <- pak_install( - chunks[[i]], - lib = lib, - upgrade = upgrade, - timeout_seconds = timeout_seconds, - label = label - ) - } - if (!run$ok) { - inform(prefix, "chunk ", i, " failed: ", run$message) - } - ok <- ok && run$ok - inform(sprintf( - "%schunk %d/%d (%d packages) %s after %.1f min", - prefix, - i, - length(chunks), - length(chunks[[i]]), - if (run$ok) { - "installed" - } else if (run$timed_out) { - "timed out" - } else { - "failed" - }, - as.numeric(difftime(Sys.time(), started, units = "mins")) - )) - } - ok -} - -install_timeout_seconds <- function() { - env_num("REVDEPX_INSTALL_TIMEOUT_MINUTES", 20) * 60 -} - -# Fingerprint of the *versions* of everything a check installs, from CRAN -# metadata. Two runs whose fingerprints agree resolved the same dependency -# tree, so an old-version check result can be carried from one to the other. -dep_fingerprint <- function(deps, db) { - if (length(deps) == 0) { - return("empty") - } - lines <- sort(paste(deps, db[deps, "Version"])) - path <- tempfile("fingerprint-") - on.exit(unlink(path)) - writeLines(lines, path) - unname(tools::md5sum(path)) -} - -# ------------------------------------------------------------ result labels -- - -# Collapse an rcmdcheck comparison (or a failure shim) into the one word the -# manifest, the collector and the retry selection agree on. -# ok -- no new problems -# newly_broken -- the dev version introduces problems the CRAN version lacks -# failed -- the check could not run to a comparable end (install -# failure, timeout, error before/around the check) -# depfail -- dependencies could not be installed, check not attempted -# deferred -- shard deadline hit before this package was checked -# error -- the shard driver itself broke on this package -# missing -- the plan named it, no shard ever reported it: the job died -# (assigned by the collector, never by a shard) -classify_status <- function(status, new_issues) { - if (status %in% c("+", "-")) { - if (identical(status, "-") && new_issues > 0) "newly_broken" else "ok" - } else { - "failed" - } -} - -# Did `R CMD check` refuse to start because a package this one needs is not -# installed? -# -# That stage is fatal: check reports the one error and stops, in two or three -# seconds, having looked at nothing. When it happens to *both* halves -- and it -# always does, since the two libraries differ only in igraph -- the pair -# compares clean, `compare_checks()` returns `+`, and the verdict is `ok`. -# -# 55 of run 31930350338's 984 `ok` results were that: every one of them a -# package whose Bioconductor dependencies are not on CRAN and so were never -# installed. `SEMgraph` is the clearest -- `1E 0W 0N` on both sides in three -# seconds, reported `ok`, while being genuinely broken by a dev change nobody -# saw because the check never ran. -# -# The check log is the only place this is visible; the status is `+` like any -# other agreeing pair. -aborted_on_dependencies <- function(check) { - errors <- check$errors %||% character() - length(errors) > 0 && - any(grepl("^checking package dependencies [.]{3} ERROR", errors)) -} - -# The packages the aborted check named, for the manifest message: the whole -# point of the class is that a reader can see *what* was missing without -# opening the artifact. -missing_dependencies <- function(check) { - errors <- check$errors %||% character() - hit <- grep( - "^checking package dependencies [.]{3} ERROR", - errors, - value = TRUE - ) - if (length(hit) == 0) { - return(character()) - } - lines <- strsplit(hit[[1]], "\n", fixed = TRUE)[[1]] - # `Packages required but not available:` puts them on the next line, quoted; - # `Package required but not available: 'x'` puts one on the same line. - named <- grep("required but not available", lines) - if (length(named) == 0) { - return(character()) - } - block <- paste( - lines[seq(named[[1]], min(named[[1]] + 1L, length(lines)))], - collapse = " " - ) - unique(gsub( - "[‘’']", - "", - regmatches( - block, - gregexpr("[‘'][^’']+[’']", block) - )[[1]] - )) -} - -# What a status that `classify_status()` can only call "failed" actually means, -# in words. A reader of the summary has to tell "broken under the dev version" -# apart from "broken everywhere" without opening the artifact, and the one word -# in the manifest cannot say it. Empty for the two statuses that compared. -status_message <- function(status) { - switch( - status, - "+" = , - "-" = "", - "i-" = "installs against the CRAN version, fails to install against the dev version", - "i+" = "fails to install against either version", - "t-" = "check timed out against the dev version, not against the CRAN version", - "t+" = "check timed out against both versions", - sprintf("check comparison inconclusive (status `%s`)", status) - ) -} - -needs_recheck <- function(result) { - !(result %in% c("ok")) -} diff --git a/.github/workflows/revdepx/watch-resources.sh b/.github/workflows/revdepx/watch-resources.sh deleted file mode 100755 index eae38b0..0000000 --- a/.github/workflows/revdepx/watch-resources.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bash -# What the machine has left, sampled while the work is still running. -# -# A job that is killed rather than failed takes its post-steps with it: when -# the runner receives a shutdown signal, `if: always()` steps never run, the -# artifact is never uploaded, and the only record that survives is what was -# already streamed to the log. So the numbers that explain such a death have -# to be emitted *during* the work, not after it -- which is what this does. -# -# Usage: -# watch-resources.sh once [label] one sample, labelled -# watch-resources.sh watch [seconds] [label] a sample every `seconds`, -# until the process is killed -# watch-resources.sh oom what the kernel killed, if -# anything -- the difference -# between "out of memory" and -# "the host went away" -# -# Every sample also goes to $RESOURCE_LOG when that is set, so a job that does -# reach its upload step carries the series in its artifact too. -# -# In `watch` mode the label may move: with $RESOURCE_PHASE_FILE set, each sample -# reads its first line and uses that instead of the fixed argument. The sampler -# outlives any one phase of the work -- that is the point of it -- so a label -# fixed when it starts is wrong for everything after. The preflight labelled -# half an hour of load-testing `installing` because of exactly this. - -set -u - -mode="${1:-once}" - -emit() { - printf '[resources] %s\n' "$1" - if [ -n "${RESOURCE_LOG:-}" ]; then - mkdir -p "$(dirname "${RESOURCE_LOG}")" 2> /dev/null || true - printf '%s\n' "$1" >> "${RESOURCE_LOG}" || true - fi -} - -# One line: clock, memory, swap, disk on the two filesystems that fill up -# here, load, and the three largest processes -- which is what names the -# thing that grew just before the machine stopped answering. -sample() { - local label="${1:-}" - local mem disk load top - if [ -n "${RESOURCE_PHASE_FILE:-}" ] && [ -r "${RESOURCE_PHASE_FILE}" ]; then - label=$(head -n 1 "${RESOURCE_PHASE_FILE}" 2> /dev/null) || label="${1:-}" - fi - - mem=$(awk ' - /^MemTotal:/ { total = $2 } - /^MemAvailable:/ { avail = $2 } - /^SwapTotal:/ { swap_total = $2 } - /^SwapFree:/ { swap_free = $2 } - END { - printf "mem %.1f/%.1fG used, %.1fG available; swap %.1f/%.1fG used", - (total - avail) / 1048576, total / 1048576, avail / 1048576, - (swap_total - swap_free) / 1048576, swap_total / 1048576 - }' /proc/meminfo) - - # `/mnt` is the runner's large ephemeral disk, and `/` the one everything - # here actually writes to; duplicates collapse, so naming a path twice or - # naming one that does not exist costs nothing. - disk=$(df -BG --output=target,avail \ - / /mnt /tmp "${RUNNER_TEMP:-/tmp}" "${TMPDIR:-/tmp}" 2> /dev/null | - awk 'NR > 1 && !seen[$1]++ { printf "%s %s free; ", $1, $2 }' | - sed 's/; $//') - - load=$(cut -d ' ' -f 1-3 < /proc/loadavg) - - top=$(ps -eo rss=,comm= --sort=-rss 2> /dev/null | - head -n 3 | - awk '{ printf "%s %.1fG; ", $2, $1 / 1048576 }' | - sed 's/; $//') - - emit "$(date -u +%H:%M:%S)${label:+ ${label}} -- ${mem}; ${disk}; load ${load}; largest: ${top}" -} - -case "${mode}" in - once) - sample "${2:-}" - ;; - watch) - interval="${2:-30}" - label="${3:-}" - while true; do - sample "${label}" - sleep "${interval}" - done - ;; - oom) - # `dmesg` needs privileges on a stock kernel; on a hosted runner sudo is - # passwordless, and where it is not, saying so beats saying nothing. - if kills=$(sudo -n dmesg 2> /dev/null | - grep -iE 'out of memory|oom-kill|oom_reaper|killed process' | - tail -n 5) && [ -n "${kills}" ]; then - emit "the kernel reports out-of-memory kills:" - printf '[resources] %s\n' "${kills}" - elif [ -n "${kills:-}" ]; then - emit "no out-of-memory kills in dmesg" - else - emit "no out-of-memory kills in dmesg (or dmesg is not readable here)" - fi - ;; - *) - echo "usage: watch-resources.sh {once [label]|watch [seconds] [label]|oom}" >&2 - exit 2 - ;; -esac