diff --git a/.github/workflows/revdep2.yaml b/.github/workflows/revdep2.yaml index 8226c8bf0e3..72d4d5860ec 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: @@ -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 @@ -149,9 +154,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 +185,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 +200,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 +209,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 +232,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 +251,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 +282,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 +305,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 +316,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 +355,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 +369,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 +379,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 +440,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 + + - 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 - - uses: r-lib/actions/setup-r@v2 + - 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 +476,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 +500,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 +509,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 +540,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 +575,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 +607,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 +616,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 +648,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 +657,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 +734,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..3456fa4be06 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 @@ -445,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 | @@ -507,11 +512,128 @@ 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*. + +### 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 | | --- | --- | | 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 | @@ -547,7 +669,11 @@ 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` | +| 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 | | Retry a run | `retry-run` | — | — | @@ -557,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 | @@ -568,7 +694,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/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 f57d5fa670c..a30565157bc 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,12 @@ # 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_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 @@ -114,6 +121,10 @@ 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") +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") @@ -351,9 +362,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)) @@ -485,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") @@ -1030,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]]) ) @@ -1076,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) @@ -1218,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 001d92a1849..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, @@ -516,6 +537,243 @@ 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" + ) +} + +# ---------------------------------------------------------- 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. 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