From 6a27be89b9562ca564fc12e1acbbf0c8cf78b983 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 10:06:31 +0000 Subject: [PATCH 1/2] feat(ci): Commit the `revdep2` report, and re-check what it lists Two options asked for after comparing `revdep2`'s results with the committed `revdep/` directory and issue #2646, one fix the comparison found, and the revdep2 changes from cynkratemplate 91c8bbb. **The report is the repository's record, so write it there.** `revdep/` already holds the four files `revdepcheck::cloud_check()` produced, the analysis a human wrote on top of them, and `run-broken.R`, which read them back to re-check what was broken. The collector now writes the same four files plus `manifest.json` and commits them to the checked ref. Only those five paths are staged: the analysis and the examples beside them are human-authored, and `pkgs/` is gigabytes of check output that belongs in the artifact. A ref that cannot take a commit -- a tag, a SHA, a fork's branch -- gets a `::notice::` and no commit, because that is a fact about the dispatch rather than a failure; the same goes for a protected branch or a losing race, which is why the step is `continue-on-error`. **`packages: broken` re-checks what that report lists**, from `manifest.json` where this workflow wrote it and otherwise from revdepcheck's own markdown -- the `# ()` headings of problems.md and failures.md, plus the "Failed to check" table in README.md. That is `run-broken.R`'s loop as an input: 39 packages instead of 771, one wave. It is a value of `packages` rather than an input of its own, because "what to check" is one question. **The timeout floor was too low.** 19 of 770 packages in run 31048405399 died on it, every one compile-heavy (Stan models, mostly) and cheap by CRAN's numbers -- 13 had the 10-minute floor as their entire budget, and 20 minutes covers all 19. **From cynkratemplate 91c8bbb**: every step carries an explicit `name`, so the Actions UI shows what a step does rather than which action it runs, and the test job closes the log group `setup-pandoc` leaves open. The template's copy is rigraph's original, so its artifact action versions are older than ours; the names and the log-group fix are taken, the versions are not. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DdNk5vJPSUidKGdnAYt7Dv --- .github/workflows/revdep2.yaml | 182 ++++++++++++++++++++++------ .github/workflows/revdep2/README.md | 55 ++++++++- .github/workflows/revdep2/plan.R | 41 ++++++- .github/workflows/revdep2/util.R | 74 +++++++++++ revdep/.gitignore | 5 + 5 files changed, 317 insertions(+), 40 deletions(-) diff --git a/.github/workflows/revdep2.yaml b/.github/workflows/revdep2.yaml index 8226c8bf0e3..7fcdce41131 100644 --- a/.github/workflows/revdep2.yaml +++ b/.github/workflows/revdep2.yaml @@ -70,7 +70,7 @@ on: type: string default: "" packages: - description: "Packages to check (comma/space separated; default: all reverse dependencies)" + description: "Packages to check (comma/space separated), or 'broken' to take what the committed revdep/ report lists as broken or failed; default: all reverse dependencies" type: string default: "" which: @@ -149,9 +149,12 @@ env: REVDEP2_PREBUILT_MAX_RUNS: ${{ vars.REVDEP2_PREBUILT_MAX_RUNS || '5' }} REVDEP2_PREBUILT_MAX_AGE_DAYS: ${{ vars.REVDEP2_PREBUILT_MAX_AGE_DAYS || '14' }} # Per-check timeout: factor times the package's CRAN check time, but never - # below the floor -- CRAN's machines are not these runners. + # below the floor -- CRAN's machines are not these runners. The floor was 10 + # minutes and killed 19 of 770 packages in run 31048405399, every one of them + # compile-heavy (Stan models, mostly) and fast on CRAN's own numbers: 13 of + # them had the floor as their whole budget. 20 minutes covers all 19. REVDEP2_TIMEOUT_FACTOR: ${{ vars.REVDEP2_TIMEOUT_FACTOR || '1.5' }} - REVDEP2_TIMEOUT_MIN_MINUTES: ${{ vars.REVDEP2_TIMEOUT_MIN_MINUTES || '10' }} + REVDEP2_TIMEOUT_MIN_MINUTES: ${{ vars.REVDEP2_TIMEOUT_MIN_MINUTES || '20' }} REVDEP2_DEADLINE_MINUTES: ${{ vars.REVDEP2_DEADLINE_MINUTES || '300' }} jobs: @@ -177,11 +180,13 @@ jobs: actions: read steps: - - uses: actions/checkout@v6 + - name: Check out the ref under test + uses: actions/checkout@v6 with: ref: ${{ inputs.ref || github.ref }} - - uses: r-lib/actions/setup-r@v2 + - name: Set up R + uses: r-lib/actions/setup-r@v2 with: use-public-rspm: true @@ -190,8 +195,8 @@ jobs: install.packages("jsonlite") shell: Rscript {0} - - id: plan - name: Plan shards + - name: Plan shards + id: plan env: GH_TOKEN: ${{ github.token }} OUT: ${{ runner.temp }}/plan.json @@ -199,7 +204,8 @@ jobs: Rscript ./.github/workflows/revdep2/plan.R shell: bash - - uses: actions/upload-artifact@v6 + - name: Upload the shard plan + uses: actions/upload-artifact@v6 if: steps.plan.outputs.shards != '0' with: name: revdep2-plan @@ -221,11 +227,13 @@ jobs: contents: read steps: - - uses: actions/checkout@v6 + - name: Check out the ref under test + uses: actions/checkout@v6 with: ref: ${{ inputs.ref || github.ref }} - - uses: ./.github/workflows/install + - name: Install R and the package dependencies + uses: ./.github/workflows/install with: token: ${{ secrets.GITHUB_TOKEN }} cache-version: revdep2-1 @@ -238,7 +246,8 @@ jobs: Rscript ./.github/workflows/revdep2/build.R shell: bash - - uses: actions/upload-artifact@v6 + - name: Upload the package binary + uses: actions/upload-artifact@v6 with: name: revdep2-pkg path: ${{ runner.temp }}/pkg @@ -268,7 +277,8 @@ jobs: actions: read steps: - - uses: actions/checkout@v6 + - name: Check out the ref under test + uses: actions/checkout@v6 with: ref: ${{ inputs.ref || github.ref }} @@ -290,7 +300,8 @@ jobs: free -g shell: bash - - uses: r-lib/actions/setup-r@v2 + - name: Set up R + uses: r-lib/actions/setup-r@v2 with: use-public-rspm: true @@ -300,14 +311,16 @@ jobs: install.packages("jsonlite") shell: Rscript {0} - - uses: actions/download-artifact@v7 + - name: Download the shard plan + uses: actions/download-artifact@v7 with: name: revdep2-plan path: ${{ runner.temp }}/plan # The preflight downloads every dependency binary once; saving the pak # cache under the plan's hash hands the shards a warm start. - - uses: actions/cache@v5 + - name: Cache the pak package cache + uses: actions/cache@v5 with: path: ~/.cache/R/pkgcache key: revdep2-pak-${{ needs.plan.outputs.plan_hash }} @@ -337,7 +350,8 @@ jobs: du -sh ~/.cache/R/pkgcache "${RUNNER_TEMP}/lib" 2>/dev/null || true shell: bash - - uses: actions/upload-artifact@v6 + - name: Upload the preflight report + uses: actions/upload-artifact@v6 if: always() with: name: revdep2-preflight @@ -350,7 +364,8 @@ jobs: # dependency universe, gigabytes of it -- so it is kept exactly as long # as a plan would still reuse it, while the index that describes it is # cheap and outlives it. - - uses: actions/upload-artifact@v6 + - name: Upload the preflight library + uses: actions/upload-artifact@v6 if: always() with: name: revdep2-lib @@ -359,7 +374,8 @@ jobs: retention-days: ${{ env.REVDEP2_PREBUILT_MAX_AGE_DAYS }} overwrite: true - - uses: actions/upload-artifact@v6 + - name: Upload the library index + uses: actions/upload-artifact@v6 if: always() with: name: revdep2-lib-index @@ -419,17 +435,29 @@ jobs: _R_CHECK_FORCE_SUGGESTS_: false steps: - - uses: actions/checkout@v6 + - name: Check out the ref under test + uses: actions/checkout@v6 with: ref: ${{ inputs.ref || github.ref }} - - uses: r-lib/actions/setup-pandoc@v2 + - name: Set up pandoc + uses: r-lib/actions/setup-pandoc@v2 - - uses: r-lib/actions/setup-r@v2 + - name: Close the log group that setup-pandoc leaves open + # See `.github/workflows/install/action.yml` for why this is needed. + # Fixed upstream by https://github.com/r-lib/actions/pull/1103. + if: runner.os == 'Linux' + run: | + echo "::endgroup::" + shell: bash + + - name: Set up R + uses: r-lib/actions/setup-r@v2 with: use-public-rspm: true - - uses: r-lib/actions/setup-tinytex@v2 + - name: Set up TinyTeX + uses: r-lib/actions/setup-tinytex@v2 - name: Install system tools for checking run: | @@ -443,19 +471,22 @@ jobs: install.packages(c("jsonlite", "rcmdcheck")) shell: Rscript {0} - - uses: actions/cache/restore@v5 + - name: Restore the pak package cache + uses: actions/cache/restore@v5 with: path: ~/.cache/R/pkgcache key: revdep2-pak-${{ needs.plan.outputs.plan_hash }} restore-keys: | revdep2-pak- - - uses: actions/download-artifact@v7 + - name: Download the shard plan + uses: actions/download-artifact@v7 with: name: revdep2-plan path: ${{ runner.temp }}/plan - - uses: actions/download-artifact@v7 + - name: Download the package binary + uses: actions/download-artifact@v7 with: name: revdep2-pkg path: ${{ runner.temp }}/pkg @@ -464,7 +495,8 @@ jobs: # building the same packages the preflight built minutes ago. It is a # `needs`, so it is always there -- unless the preflight could not pack # one, which is a slower shard, not a broken one. - - uses: actions/download-artifact@v7 + - name: Download this run's preflight library + uses: actions/download-artifact@v7 continue-on-error: true with: name: revdep2-lib @@ -472,7 +504,8 @@ jobs: # The baseline lives on an earlier run; absence is not an error, the # shard just checks the CRAN version fresh. - - uses: actions/download-artifact@v7 + - name: Download the baseline results + uses: actions/download-artifact@v7 if: needs.plan.outputs.baseline_run != '0' continue-on-error: true with: @@ -502,7 +535,8 @@ jobs: # Named per attempt: a re-run of one shard must not overwrite the results # the other shards uploaded in the first attempt; the collector reads # every attempt and lets the later one win per package. - - uses: actions/upload-artifact@v6 + - name: Upload the shard results + uses: actions/upload-artifact@v6 if: always() with: name: revdep2-results-${{ matrix.shard }}-${{ github.run_attempt }} @@ -536,16 +570,19 @@ jobs: name: "Collect results and report" permissions: - contents: read + # To commit the report back to the checked branch. + contents: write # To download the retried run's report artifact, which lives on that run. actions: read steps: - - uses: actions/checkout@v6 + - name: Check out the ref under test + uses: actions/checkout@v6 with: ref: ${{ inputs.ref || github.ref }} - - uses: r-lib/actions/setup-r@v2 + - name: Set up R + uses: r-lib/actions/setup-r@v2 with: use-public-rspm: true @@ -565,7 +602,8 @@ jobs: pak::pkg_install("krlmlr/revdepcheck") shell: Rscript {0} - - uses: actions/download-artifact@v7 + - name: Download the shard plan + uses: actions/download-artifact@v7 with: name: revdep2-plan path: ${{ runner.temp }}/plan @@ -573,13 +611,15 @@ jobs: # Tolerated because a run in which no shard uploaded anything is still # worth a report: the collector reconciles against the plan and names # every package it never heard about, which is what `retry-run` needs. - - uses: actions/download-artifact@v7 + - name: Download the results of all shards + uses: actions/download-artifact@v7 continue-on-error: true with: pattern: revdep2-results-* path: ${{ runner.temp }}/results - - uses: actions/download-artifact@v7 + - name: Download the report of the run being retried + uses: actions/download-artifact@v7 if: env.REVDEP2_RETRY_RUN != '' continue-on-error: true with: @@ -603,7 +643,8 @@ jobs: Rscript ./.github/workflows/revdep2/collect.R shell: bash - - uses: actions/upload-artifact@v6 + - name: Upload the report + uses: actions/upload-artifact@v6 if: always() with: name: revdep2-report @@ -611,7 +652,73 @@ jobs: retention-days: 90 overwrite: true - - uses: actions/upload-artifact@v6 + # The report is also the repository's own record: `revdep/README.md` and + # friends have been committed here since `revdepcheck::cloud_check()` + # wrote them, and `recheck-report` reads them back. Only the four + # generated files and the manifest are staged -- the analysis, the + # examples and the notification scripts next to them are human-authored, + # and `pkgs/` is gigabytes of check output that belongs in the artifact + # only. + # + # A ref that is not a branch (a tag, a SHA, a fork's ref) cannot receive + # a commit; that is a fact about the dispatch, not a failure, so the step + # says so and stops. `continue-on-error` covers the rest: a protected + # branch, a read-only token on a fork, a race with another push. The + # report is in the artifact either way. + - name: Commit the report to the checked ref + if: always() && vars.REVDEP2_COMMIT_REPORT != 'false' + continue-on-error: true + env: + REF_INPUT: ${{ inputs.ref }} + run: | + set -eu + # Without a `ref` input the dispatch decides, and GitHub says outright + # what kind of ref it was; with one, only the remote can say whether + # it names a branch. Anything else -- a tag, a SHA, a branch that does + # not exist here -- has nowhere to put a commit. + if [ -z "${REF_INPUT}" ]; then + REF="${GITHUB_REF_NAME}" + if [ "${GITHUB_REF_TYPE}" != "branch" ]; then + echo "::notice::${REF} is a ${GITHUB_REF_TYPE}, not a branch; the report stays in the artifact only." + exit 0 + fi + else + REF="${REF_INPUT}" + if ! git ls-remote --exit-code --heads origin "${REF}" > /dev/null 2>&1; then + echo "::notice::${REF} is not a branch of this repository; the report stays in the artifact only." + exit 0 + fi + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + # Only what this run generated, and only what it managed to write: + # a collector that fell back to the manifest-derived summary has a + # README.md and no problems.md, which is a report, not an error. + files="" + for f in README.md problems.md failures.md cran.md manifest.json; do + if [ -f "revdep/${f}" ]; then + files="${files} revdep/${f}" + fi + done + if [ -z "${files}" ]; then + echo "::notice::No report files were written; nothing to commit." + exit 0 + fi + git add -- ${files} + if git diff --cached --quiet; then + echo "::notice::The report is unchanged; nothing to commit." + exit 0 + fi + git commit -m "chore: Update revdep report from run ${GITHUB_RUN_ID}" \ + -m "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + git fetch origin "${REF}" + git rebase "origin/${REF}" + git push origin "HEAD:${REF}" + echo "::notice::Report committed to ${REF}." + shell: bash + + - name: Upload the new baseline + uses: actions/upload-artifact@v6 if: always() with: name: revdep2-baseline @@ -622,7 +729,8 @@ jobs: # What the run cost, per package and per shard. Small on purpose and # separate from the report: the next plan downloads it to calibrate its # cost model, and should not have to fetch a report to do so. - - uses: actions/upload-artifact@v6 + - name: Upload the measured timings + uses: actions/upload-artifact@v6 if: always() with: name: revdep2-timings diff --git a/.github/workflows/revdep2/README.md b/.github/workflows/revdep2/README.md index c5fd354d252..ff451b6cc35 100644 --- a/.github/workflows/revdep2/README.md +++ b/.github/workflows/revdep2/README.md @@ -100,6 +100,11 @@ The per-check timeout stays on CRAN's number and is not calibrated: 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 @@ -507,11 +512,55 @@ 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. + +Only those five paths are staged. +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 under both versions | `ok` (no *new* problems), visible in the report's tables | | A check times out | rcmdcheck kills it at `max(floor, factor × its CRAN time)`; compared as `t-`, reported `failed` | | A revdep's strong dependencies cannot install | `depfail`, check not attempted, named in the shard summary | @@ -547,7 +596,9 @@ at the next `if`. | Knob | Input | Variable | Default | | --- | --- | --- | --- | | Ref to check (branch, tag, SHA) | `ref` | — | the dispatched ref | -| Packages to check | `packages` | — | all revdeps | +| 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` | — | — | @@ -568,7 +619,7 @@ at the next `if`. | 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` | 10 | +| Per-check timeout floor | — | `REVDEP2_TIMEOUT_MIN_MINUTES` | 20 | | Shard graceful deadline | — | `REVDEP2_DEADLINE_MINUTES` | 300 | ## Prior art diff --git a/.github/workflows/revdep2/plan.R b/.github/workflows/revdep2/plan.R index f57d5fa670c..90002458bec 100644 --- a/.github/workflows/revdep2/plan.R +++ b/.github/workflows/revdep2/plan.R @@ -30,7 +30,8 @@ # # Environment variables (inputs): # REVDEP2_PACKAGES - explicit packages to check (comma/space separated; -# default: all reverse dependencies) +# 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 @@ -38,6 +39,9 @@ # 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 @@ -114,6 +118,8 @@ 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") @@ -351,9 +357,42 @@ packages_input <- trimws(strsplit( )[[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)) diff --git a/.github/workflows/revdep2/util.R b/.github/workflows/revdep2/util.R index 001d92a1849..95e36850e4e 100644 --- a/.github/workflows/revdep2/util.R +++ b/.github/workflows/revdep2/util.R @@ -516,6 +516,80 @@ restore_prebuilt <- function(plan, lib, wanted) { 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. diff --git a/revdep/.gitignore b/revdep/.gitignore index b06d4d82019..6ff7b4db622 100644 --- a/revdep/.gitignore +++ b/revdep/.gitignore @@ -2,3 +2,8 @@ /cloud /review/ /notifications/ +# Written by the revdep2 collector into this directory; the reports next to +# them are committed, these are not: `pkgs/` is the raw check output (the +# `revdep2-report` artifact has it), `run.json` is per-run metadata. +/pkgs/ +/run.json From f9833937ea8379d06649e21f1455834ddc41ca99 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 14:42:17 +0000 Subject: [PATCH 2/2] feat(ci): Check revdeps from r-universe, installed the way `rcc-dev` installs Stacked on the report changes so it can be reviewed on its own; the first commit is those changes squashed and can be dropped once they merge. A revdep that breaks against the dev version has often been fixed upstream already, and the fix sits on r-universe for weeks before it reaches CRAN -- which is exactly what the maintainer PRs tracked in #2646 are waiting on. `r-universe: thomasp85,stocnet,natverse` names the universes to consult. Any package they build in a version newer than CRAN's is checked from there instead: tidygraph 1.3.1.9000, ggraph 2.2.2.9000, netrics 0.4.1, nat 1.11.0 against CRAN's 1.3.1, 2.2.2, 0.4.0, 1.8.25. Each universe is an ordinary CRAN-like repository, so `available.packages()` reads it and `download.packages()` fetches the source the check runs on; the version the plan records is the one checked, which keeps baseline reuse honest. Only newer versions are taken -- re-checking a rebuild of what CRAN already has answers nothing. Checking a package from r-universe is one thing; its dependents in the same shard are another, and that is the problem `.github/workflows/R-CMD-check-dev.yaml` already solves for our own dependencies. This uses its mechanism verbatim: `remotes::install_runiverse(pkg, linux_distro = "resolute")` off the branch of remotes that carries it, with the distro naming the runner's release because r-universe builds its Linux binaries for `ubuntu:latest` -- get it wrong and every dev version is compiled from source. The universe is passed in rather than looked up, since the plan resolved it already. So the preflight preloads both repositories. They are the same package names at different versions and cannot share a library, so the r-universe builds go into a second one, load-tested with the dev build first on the path and packed as `library-runiverse.tar` beside `library.tar` in the same artifact. A shard installs its CRAN union as before and lays that library over the result -- in that order, because pak would otherwise resolve the dev build back to CRAN's, and before both check phases, so old and new still differ in exactly one thing. A run without a usable preflight library installs the dev builds itself. `r-universe` takes the dispatch slot `baseline-max-age-days` had; that knob stays available as a repository variable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DdNk5vJPSUidKGdnAYt7Dv --- .github/workflows/revdep2.yaml | 15 +- .github/workflows/revdep2/README.md | 79 +++++++++- .github/workflows/revdep2/collect.R | 25 ++++ .github/workflows/revdep2/plan.R | 97 +++++++++++++ .github/workflows/revdep2/preflight.R | 59 +++++++- .github/workflows/revdep2/shard.R | 54 ++++++- .github/workflows/revdep2/util.R | 202 ++++++++++++++++++++++++-- 7 files changed, 512 insertions(+), 19 deletions(-) diff --git a/.github/workflows/revdep2.yaml b/.github/workflows/revdep2.yaml index 7fcdce41131..72d4d5860ec 100644 --- a/.github/workflows/revdep2.yaml +++ b/.github/workflows/revdep2.yaml @@ -88,6 +88,10 @@ on: description: "Run id of an earlier revdep2 run; re-check only its not-ok packages" type: string default: "" + r-universe: + description: "Comma-separated r-universe names (or 'auto') whose newer builds replace the CRAN sources, e.g. 'thomasp85,stocnet'" + type: string + default: "" part: description: "Check one G-th of the reverse dependencies, as 'i/G'; for a set too big for one run (the plan says when, and with which G)" type: string @@ -104,10 +108,6 @@ on: description: "Re-check the CRAN version even where a baseline is reusable" type: boolean default: false - baseline-max-age-days: - description: "Oldest baseline result worth reusing" - type: string - default: "" dry-run: description: "Plan only: report the shards and reuse decisions, start no checks" type: boolean @@ -134,6 +134,11 @@ env: REVDEP2_DEPTH: ${{ inputs.depth || '1' }} REVDEP2_RETRY_RUN: ${{ inputs.retry-run || '' }} REVDEP2_PART: ${{ inputs.part || '' }} + REVDEP2_UNIVERSE: ${{ inputs.r-universe || vars.REVDEP2_UNIVERSE || '' }} + # r-universe publishes Linux binaries for `ubuntu:latest`, which now tracks + # 26.04 "resolute"; this must name the release the jobs above run on, exactly + # as .github/workflows/R-CMD-check-dev.yaml does for our own dependencies. + REVDEP2_UNIVERSE_DISTRO: ${{ vars.REVDEP2_UNIVERSE_DISTRO || 'resolute' }} REVDEP2_SHARD_BUDGET_MINUTES: ${{ inputs.shard-budget-minutes || vars.REVDEP2_SHARD_BUDGET_MINUTES || '45' }} REVDEP2_MAX_PARALLEL: ${{ inputs.max-parallel || vars.REVDEP2_MAX_PARALLEL || '20' }} # Check minutes one shard may be planned to hold. Only a batch too big for @@ -141,7 +146,7 @@ env: # how many waves there are; empty means 80% of REVDEP2_DEADLINE_MINUTES. REVDEP2_SHARD_CAPACITY_MINUTES: ${{ vars.REVDEP2_SHARD_CAPACITY_MINUTES || '' }} REVDEP2_REFRESH_BASELINE: ${{ inputs.refresh-baseline && '1' || '' }} - REVDEP2_BASELINE_MAX_AGE_DAYS: ${{ inputs.baseline-max-age-days || vars.REVDEP2_BASELINE_MAX_AGE_DAYS || '30' }} + REVDEP2_BASELINE_MAX_AGE_DAYS: ${{ vars.REVDEP2_BASELINE_MAX_AGE_DAYS || '30' }} REVDEP2_DRY_RUN: ${{ inputs.dry-run && '1' || '' }} # Prebuilt dependency libraries of earlier runs: how many runs may donate one # (0 turns reuse off), and how old a library may be before its binaries are diff --git a/.github/workflows/revdep2/README.md b/.github/workflows/revdep2/README.md index ff451b6cc35..3456fa4be06 100644 --- a/.github/workflows/revdep2/README.md +++ b/.github/workflows/revdep2/README.md @@ -450,7 +450,7 @@ Every artifact this workflow writes: | `revdep2-plan` | `plan.json` | 30 days | | `revdep2-pkg` | source tarball, platform binary, `meta.json` | 30 days | | `revdep2-preflight` | `depfail.json` | 30 days | -| `revdep2-lib` | `library.tar` (the preflight's installed library), `lib.json` | 14 days | +| `revdep2-lib` | `library.tar` (the preflight's installed library), `lib.json`, and with `r-universe` also `library-runiverse.tar` | 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`, `problems.md`, `failures.md`, `cran.md`, `manifest.json`, all `pkgs/` | 90 days | @@ -554,6 +554,77 @@ 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*. +### Checking the fix instead of the breakage + +A revdep that breaks against the dev version has often been fixed already: +the maintainer merged a PR, and the fix sits on r-universe +for weeks before it reaches CRAN. +`r-universe: thomasp85,stocnet,natverse` names the universes to consult; +any package they build in a version newer than CRAN's +is checked from there instead, and the plan says which ones: + +| package | CRAN | checked instead | +| --- | --- | --- | +| `tidygraph` | 1.3.1 | 1.3.1.9000 (thomasp85) | +| `ggraph` | 2.2.2 | 2.2.2.9000 (thomasp85) | +| `netrics` | 0.4.0 | 0.4.1 (stocnet) | +| `nat` | 1.8.25 | 1.11.0 (natverse) | + +Each universe is an ordinary CRAN-like repository, +so `available.packages()` reads it and `download.packages()` fetches from it; +no bespoke client, and the version recorded in the plan +is the one that is checked — which also keeps baseline reuse honest, +since a baseline is keyed on the revdep's version. +Only *newer* versions are taken: +re-checking a rebuild of what CRAN already has answers nothing. + +`r-universe: auto` asks `r-universe.dev` which universe builds each package, +which needs that host reachable and finds nothing when it is not; +naming the universes is the reliable form, +and the maintainer PRs tracked in the revdep issue are where the names +come from. + +Dependencies still resolve against CRAN metadata, +so a dev version that added a dependency CRAN does not have yet +will fail to install rather than being silently checked against the wrong +tree. + +### Preloading both, the way `rcc-dev` installs + +Checking a package from r-universe is one thing; +its *dependents* in the same shard are another. +`.github/workflows/R-CMD-check-dev.yaml` already solves that problem +for this package's own dependencies — +`remotes::install_runiverse(pkg, linux_distro = "resolute")`, +from the branch of remotes that carries it — +and this uses the same call, with the same reason for the distro: +r-universe builds Linux binaries for `ubuntu:latest`, +so the name has to match the runner's release, +or every dev version is compiled from source instead. +The universe is passed in rather than looked up, +because the plan resolved it already. + +Which means the preflight has to preload **both** repositories: +the CRAN dependency universe as before, +and the r-universe builds of whatever the plan resolved there. +They are the same package names at different versions, +so they cannot share one library — +the preflight installs them into a second one, +load-tests it with the dev build first on the path, +and packs it as `library-runiverse.tar` beside `library.tar` +in the same `revdep2-lib` artifact. + +A shard then does what it always did, and one thing more: +pak resolves its whole union against CRAN, +and only then is the r-universe library laid *over* the result, +for the packages that shard installs. +Order matters — pak would otherwise resolve the dev build back to CRAN's — +and so does the fact that this happens before *both* check phases, +so the old and the new check still differ in exactly one thing, +the version of the package under test. +A shard whose run has no usable preflight library +installs the dev builds itself, through the same `install_runiverse()`. + ## Failure modes | Situation | Outcome | @@ -561,6 +632,8 @@ the report is about *results*, a retry is about *coverage*. | 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 package is not in any named r-universe | it is checked from CRAN, like everything else | +| `install_runiverse()` is unavailable or fails | the dev build is skipped with a log line; the CRAN version is checked instead | | A revdep fails under both versions | `ok` (no *new* problems), visible in the report's tables | | A check times out | rcmdcheck kills it at `max(floor, factor × its CRAN time)`; compared as `t-`, reported `failed` | | A revdep's strong dependencies cannot install | `depfail`, check not attempted, named in the shard summary | @@ -598,6 +671,8 @@ at the next `if`. | 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` | +| r-universe names whose newer builds replace the CRAN sources | `r-universe` | `REVDEP2_UNIVERSE` | none | +| Linux release r-universe binaries are taken for | — | `REVDEP2_UNIVERSE_DISTRO` | `resolute` (ubuntu-26.04) | | Commit the report back to the checked branch | — | `REVDEP2_COMMIT_REPORT` | on | | Revdep set | `which` | — | `strong` | | Revdep depth (`1`, `2`, …, `all`) | `depth` | — | 1 | @@ -608,7 +683,7 @@ at the next `if`. | 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 | +| Oldest reusable baseline | — | `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 | diff --git a/.github/workflows/revdep2/collect.R b/.github/workflows/revdep2/collect.R index ab053a6b987..be416b4bc08 100644 --- a/.github/workflows/revdep2/collect.R +++ b/.github/workflows/revdep2/collect.R @@ -539,6 +539,31 @@ append_summary(c( "", headline, "", + # A package checked from r-universe answers a different question than the + # rest of the table -- "is the maintainer's fix enough?" rather than "does + # CRAN's version still work?" -- so the reader has to be told which ones. + local({ + dev <- Filter(function(e) !identical(e$source %||% "cran", "cran"), entries) + if (length(dev) == 0) { + NULL + } else { + c( + sprintf( + "%d package(s) were checked from r-universe rather than CRAN: %s.", + length(dev), + paste( + vapply( + unname(dev), + function(e) sprintf("`%s` %s (%s)", e$package, e$version, e$source), + character(1) + ), + collapse = ", " + ) + ), + "" + ) + } + }), md_table(counts_df), "", readme, diff --git a/.github/workflows/revdep2/plan.R b/.github/workflows/revdep2/plan.R index 90002458bec..a30565157bc 100644 --- a/.github/workflows/revdep2/plan.R +++ b/.github/workflows/revdep2/plan.R @@ -42,6 +42,9 @@ # 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_UNIVERSE - r-universe names, or `auto`: any package they +# build in a version newer than CRAN's is checked +# from there instead (default: none) # 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 @@ -120,6 +123,8 @@ 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") +runiverse <- trimws(strsplit(env_chr("REVDEP2_UNIVERSE"), "[,[:space:]]+")[[1]]) +runiverse <- runiverse[nzchar(runiverse)] overhead_minutes <- env_num("REVDEP2_PACKAGE_OVERHEAD_MINUTES", 0.5) retry_run <- env_chr("REVDEP2_RETRY_RUN") repo <- env_chr("GITHUB_REPOSITORY") @@ -524,6 +529,52 @@ inform( "s for the rest" ) +# ------------------------------------------------------------- r-universe ---- + +# Which packages to check from a dev source instead of from CRAN. +# +# A revdep that breaks against the dev version has often been fixed by its +# maintainer already, and the fix sits on r-universe for weeks before it +# reaches CRAN. Checking that build answers the question the CRAN tarball +# cannot: is this still broken, or is it only CRAN that has not caught up? +# +# Only versions newer than CRAN's are taken, and the version recorded here is +# the one that is checked -- which is also what keeps baseline reuse honest, +# since a baseline is keyed on the revdep's version. +runiverse_sources <- list() +if (length(runiverse) > 0) { + runiverse_sources <- universe_sources(runiverse, packages, their_version) + if (length(runiverse_sources) > 0) { + for (p in names(runiverse_sources)) { + their_version[[p]] <- runiverse_sources[[p]]$version + } + inform( + length(runiverse_sources), + " of ", + length(packages), + " package(s) come from r-universe: ", + paste( + utils::head( + sprintf( + "%s %s (%s)", + names(runiverse_sources), + vapply(runiverse_sources, function(s) s$version, character(1)), + vapply(runiverse_sources, function(s) s$universe, character(1)) + ), + 6 + ), + collapse = ", " + ), + if (length(runiverse_sources) > 6) ", ..." else "" + ) + } else { + inform( + "No package has a version newer than CRAN's in: ", + paste(runiverse, collapse = ", ") + ) + } +} + # --------------------------------------------------------------- closures ---- inform("Computing dependency closures") @@ -1069,6 +1120,14 @@ shard_list <- lapply(seq_len(k), function(s) { t_total = unname(t_total[[p]]), check_seconds = round(unname(check_seconds[[p]])), timing_source = timing_source[[match(p, packages)]], + # Where the shard downloads this package's source from; absent means + # CRAN, which is the overwhelming majority. + source = if (is.null(runiverse_sources[[p]])) { + "cran" + } else { + runiverse_sources[[p]]$universe + }, + source_repo = runiverse_sources[[p]]$repo, dep_fingerprint = unname(fingerprint[[p]]), baseline = unname(reuse[[p]]) ) @@ -1115,6 +1174,21 @@ plan <- list( missing = length(history$missing), runs = prebuilt ), + r_universe = list( + universes = as.list(runiverse), + # r-universe builds Linux binaries for `ubuntu:latest`; this has to name + # the runner's release, the way R-CMD-check-dev.yaml does. + linux_distro = env_chr("REVDEP2_UNIVERSE_DISTRO", "resolute"), + packages = length(runiverse_sources), + sources = lapply(names(runiverse_sources), function(p) { + list( + package = p, + version = runiverse_sources[[p]]$version, + universe = runiverse_sources[[p]]$universe, + repo = runiverse_sources[[p]]$repo + ) + }) + ), calibration = list( runs = if (nzchar(local_measured)) { as.list(local_measured) @@ -1257,6 +1331,29 @@ append_summary(c( "none" } ), + if (length(runiverse) > 0) { + sprintf( + "| r-universe | %d of %d package(s) checked from %s instead of CRAN%s |", + length(runiverse_sources), + n, + paste(runiverse, collapse = ", "), + if (length(runiverse_sources) > 0) { + paste0( + ": ", + paste( + sprintf( + "`%s` %s", + names(runiverse_sources), + vapply(runiverse_sources, function(s) s$version, character(1)) + ), + collapse = ", " + ) + ) + } else { + "" + } + ) + }, sprintf( "| Prebuilt packages | %s |", if (length(prebuilt) > 0) { diff --git a/.github/workflows/revdep2/preflight.R b/.github/workflows/revdep2/preflight.R index 490941cb80a..14a78501f50 100644 --- a/.github/workflows/revdep2/preflight.R +++ b/.github/workflows/revdep2/preflight.R @@ -92,11 +92,11 @@ if (!installed_ok) { # namespace names itself. installed <- intersect(install_union, rownames(utils::installed.packages(lib))) inform("Preflight: loading ", length(installed), " packages") -load_batch <- function(pkgs) { +load_batch <- function(pkgs, libs = lib) { script <- tempfile(fileext = ".R") writeLines( c( - sprintf(".libPaths(c(%s, .libPaths()))", deparse(lib)), + sprintf(".libPaths(c(%s, .libPaths()))", deparse(libs)), "for (p in commandArgs(trailingOnly = TRUE)) {", " loadNamespace(p)", " writeLines(paste0('LOADED ', p))", @@ -172,6 +172,48 @@ for (p in names(load_failures)) { ) } +# ------------------------------------------------------------- r-universe -- + +# The second half of "preload everything": where the plan resolved packages to +# r-universe, their dev builds are installed too, into a library of their own, +# and packed beside the CRAN one. Two libraries rather than one because they +# hold the same package names at different versions -- the shards install the +# CRAN union first and lay these over it, so the dev build is what a check +# sees, and neither has to be built again. +runiverse_lib <- file.path( + env_chr("RUNNER_TEMP", tempdir()), + "revdep2-preflight-lib-runiverse" +) +runiverse_sources <- plan$r_universe$sources %||% list() +runiverse_packed <- character() +if (length(runiverse_sources) > 0) { + inform( + "Preflight: installing ", + length(runiverse_sources), + " r-universe build(s)" + ) + runiverse_install( + runiverse_sources, + runiverse_lib, + env_chr("REVDEP2_UNIVERSE_DISTRO", "resolute") + ) + # Load-tested with the dev build first on the path, which is the order a + # shard will see it in. + for (p in list.dirs(runiverse_lib, full.names = FALSE, recursive = FALSE)) { + loaded <- load_batch(p, libs = c(runiverse_lib, lib)) + if (length(loaded$failed) > 0) { + failures[[length(failures) + 1]] <- list( + package = p, + phase = "load", + message = paste( + utils::tail(sanitize_log(loaded$log), 20), + collapse = "\n" + ) + ) + } + } +} + write_json(failures, file.path(out_dir, "depfail.json")) # ------------------------------------------------------------------ library -- @@ -186,6 +228,19 @@ if (nzchar(lib_out)) { if (nzchar(index_out)) index_out else NULL ) inform("Preflight: published ", length(packed), " package(s) for later runs") + if (length(runiverse_sources) > 0) { + runiverse_packed <- pack_library( + runiverse_lib, + lib_out, + if (nzchar(index_out)) index_out else NULL, + name = "library-runiverse" + ) + inform( + "Preflight: published ", + length(runiverse_packed), + " r-universe build(s) for the shards" + ) + } } append_summary(c( diff --git a/.github/workflows/revdep2/shard.R b/.github/workflows/revdep2/shard.R index ace9ff7e6f2..ab2036db98a 100644 --- a/.github/workflows/revdep2/shard.R +++ b/.github/workflows/revdep2/shard.R @@ -94,6 +94,7 @@ for (p in shard$packages) { version = p$version, level = p$level %||% 0L, shard = shard_index, + source = p$source %||% "cran", weight_minutes = p$weight_minutes, t_total = p$t_total %||% 0, dep_fingerprint = p$dep_fingerprint, @@ -186,6 +187,44 @@ if (!bulk_ok) { } } +# The r-universe overlay goes on last, deliberately: pak has just resolved the +# whole union against CRAN, so any dev build it should shadow has been +# installed over. What lands here is what the checks -- both phases of them -- +# will see, so the comparison still differs in one thing only, the version of +# the package under test. +# +# The preflight built these, so normally this is an unpack; only a run without +# a usable preflight library installs them itself, the same way rcc-dev does. +runiverse_sources <- Filter( + function(s) s$package %in% install, + plan$r_universe$sources %||% list() +) +if (length(runiverse_sources) > 0) { + wanted <- vapply(runiverse_sources, function(s) s$package, character(1)) + restored_dev <- restore_local_library( + env_chr("LIB_DIR"), + lib, + wanted, + name = "library-runiverse", + over = TRUE + ) + missing_dev <- setdiff(wanted, restored_dev) + if (length(missing_dev) > 0) { + runiverse_install( + Filter(function(s) s$package %in% missing_dev, runiverse_sources), + lib, + env_chr("REVDEP2_UNIVERSE_DISTRO", "resolute") + ) + } + inform( + "r-universe: ", + length(restored_dev), + " dev build(s) unpacked, ", + length(missing_dev), + " installed" + ) +} + install_seconds <- elapsed(install_started) inform( "Dependencies ready after ", @@ -263,6 +302,19 @@ for (name in members) { src_dir <- file.path(work, "src") dir.create(src_dir, showWarnings = FALSE) + +# Where each package's tarball comes from. CRAN for almost everything; an +# r-universe repository for the packages the plan resolved to a newer build +# there, which is how a fix that has not reached CRAN yet gets checked. +source_repo <- stats::setNames( + vapply( + shard$packages, + function(p) p$source_repo %||% cran_repo(), + character(1) + ), + members +) + sources <- list() for (name in runnable) { tarball <- tryCatch( @@ -270,7 +322,7 @@ for (name in runnable) { hit <- utils::download.packages( name, destdir = src_dir, - repos = cran_repo(), + repos = source_repo[[name]], type = "source", quiet = TRUE ) diff --git a/.github/workflows/revdep2/util.R b/.github/workflows/revdep2/util.R index 95e36850e4e..105d374050d 100644 --- a/.github/workflows/revdep2/util.R +++ b/.github/workflows/revdep2/util.R @@ -345,7 +345,7 @@ library_versions <- function(lib) { versions[!is.na(versions)] } -pack_library <- function(lib, dest, index_dest = NULL) { +pack_library <- function(lib, dest, index_dest = NULL, name = "library") { versions <- library_versions(lib) dir.create(dest, recursive = TRUE, showWarnings = FALSE) index <- list( @@ -364,12 +364,17 @@ pack_library <- function(lib, dest, index_dest = NULL) { versions )) ) - write_json(index, file.path(dest, "lib.json")) + index_name <- if (identical(name, "library")) { + "lib.json" + } else { + paste0(name, ".json") + } + write_json(index, file.path(dest, index_name)) 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"), + file.path(dest, index_name), + file.path(index_dest, index_name), overwrite = TRUE ) } @@ -380,7 +385,7 @@ pack_library <- function(lib, dest, index_dest = NULL) { members <- tempfile("members-") writeLines(names(versions), members) on.exit(unlink(members)) - tarball <- file.path(dest, "library.tar") + tarball <- file.path(dest, paste0(name, ".tar")) status <- system2( "tar", # Quoted: system2() quotes the command, but not the arguments. @@ -449,13 +454,29 @@ unpack_library <- function(tarball, lib, take) { # 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") +restore_local_library <- function( + dir, + lib, + wanted, + name = "library", + over = FALSE +) { + tarball <- file.path(dir, paste0(name, ".tar")) if (!nzchar(dir) || !file.exists(tarball)) { return(character()) } - take <- missing_from(lib, wanted) - index <- file.path(dir, "lib.json") + # The r-universe overlay is the one case where an already-installed package + # has to be replaced rather than skipped: pak has just resolved the whole + # union against CRAN, so the dev build it should shadow is sitting there. + take <- if (over) { + setdiff(unique(unlist(wanted, use.names = FALSE)), loadedNamespaces()) + } else { + missing_from(lib, wanted) + } + index <- file.path( + dir, + if (identical(name, "library")) "lib.json" else paste0(name, ".json") + ) if (file.exists(index)) { have <- vapply( read_json(index)$packages, @@ -590,6 +611,169 @@ report_packages <- function(dir) { ) } +# ---------------------------------------------------------- r-universe ------- + +# Where a package can be had in a version newer than CRAN's. +# +# A revdep that breaks against the dev version has often been fixed upstream +# already, and r-universe is where that fix is built and installable before it +# reaches CRAN. Each universe serves an ordinary CRAN-like repository, so +# `available.packages()` reads it and `download.packages()` fetches from it -- +# no bespoke client, and the `Repository` column carries the exact tarball URL. +# +# `universes` are names (`r-lib`) or full URLs; `"auto"` asks r-universe.dev +# itself which universe builds each package, which needs that host reachable +# and simply finds nothing when it is not. +universe_repo <- function(universe) { + if (grepl("^https?://", universe)) { + sub("/+$", "", universe) + } else { + sprintf("https://%s.r-universe.dev", universe) + } +} + +universe_owner <- function(package) { + url <- sprintf("https://r-universe.dev/api/packages/%s", package) + info <- tryCatch( + suppressWarnings(jsonlite::fromJSON(url, simplifyVector = FALSE)), + error = function(e) NULL + ) + owner <- info[["_user"]] %||% info[["_owner"]] %||% NULL + if (is.character(owner) && length(owner) == 1 && nzchar(owner)) { + owner + } else { + NULL + } +} + +# Package -> list(version, repo) for everything `packages` can be had from +# `universes` in a version newer than `cran_version[[package]]`. Older or equal +# versions are ignored: checking a stale rebuild of what CRAN already has +# answers nothing, and it would invalidate the baseline for no reason. +# Installing from r-universe, the way `.github/workflows/R-CMD-check-dev.yaml` +# does it: `remotes::install_runiverse()` off the branch that carries it, which +# takes the Linux binaries r-universe builds for `ubuntu:latest` rather than +# compiling every dev version again. `linux_distro` has to name the runner's +# release ("resolute" for ubuntu-26.04), and the universe is passed explicitly +# because the plan already resolved it -- that also skips the API lookup +# `install_runiverse()` would otherwise do per package. +# +# The function takes no `...`, so the library is chosen the only way it can be: +# by putting it first on the search path for the duration. +runiverse_remotes <- function() { + ok <- function() { + requireNamespace("remotes", quietly = TRUE) && + "install_runiverse" %in% getNamespaceExports("remotes") + } + if (ok()) { + return(TRUE) + } + inform("Installing remotes with install_runiverse()") + tryCatch( + pak::pkg_install("r-lib/remotes@f-618-universe", ask = FALSE), + error = function(e) inform("Could not install it: ", conditionMessage(e)) + ) + ok() +} + +# `sources` is the plan's r-universe list: package, universe, version. +runiverse_install <- function(sources, lib, distro) { + if (length(sources) == 0) { + return(character()) + } + if (!runiverse_remotes()) { + inform("r-universe: install_runiverse() unavailable; nothing installed") + return(character()) + } + dir.create(lib, recursive = TRUE, showWarnings = FALSE) + before <- .libPaths() + on.exit(.libPaths(before), add = TRUE) + .libPaths(c(lib, before)) + installed <- character() + for (source in sources) { + done <- tryCatch( + { + remotes::install_runiverse( + source$package, + universe = source$universe, + linux_distro = distro + ) + TRUE + }, + error = function(e) { + inform( + "r-universe: ", + source$package, + " from ", + source$universe, + " failed: ", + conditionMessage(e) + ) + FALSE + } + ) + if (done && dir.exists(file.path(lib, source$package))) { + installed <- c(installed, source$package) + } + } + inform( + "r-universe: installed ", + length(installed), + " of ", + length(sources), + " package(s) into ", + lib + ) + installed +} + +universe_sources <- function(universes, packages, cran_version) { + out <- list() + if (length(universes) == 0 || length(packages) == 0) { + return(out) + } + if (identical(universes, "auto")) { + owners <- unique(Filter( + Negate(is.null), + lapply(packages, universe_owner) + )) + universes <- unlist(owners, use.names = FALSE) + inform( + "r-universe: ", + length(universes), + " universe(s) discovered for ", + length(packages), + " package(s)" + ) + } + for (universe in unique(universes)) { + repo <- universe_repo(universe) + db <- tryCatch( + suppressWarnings(utils::available.packages( + repos = repo, + type = "source" + )), + error = function(e) NULL + ) + if (is.null(db) || nrow(db) == 0) { + inform("r-universe: ", repo, " has no readable package index") + next + } + for (p in intersect(packages, rownames(db))) { + version <- unname(db[p, "Version"]) + if ( + !is.null(out[[p]]) || + is.na(version) || + package_version(version) <= package_version(cran_version[[p]]) + ) { + next + } + out[[p]] <- list(version = version, repo = repo, universe = universe) + } + } + out +} + # --------------------------------------------------------- measured timings -- # What a run measured about itself, so the next plan can stop guessing.