forked from duckdb/duckdb-r
-
Notifications
You must be signed in to change notification settings - Fork 0
898 lines (820 loc) · 39.5 KB
/
Copy pathrevdep2.yaml
File metadata and controls
898 lines (820 loc) · 39.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
# Sharded reverse-dependency check:
# plan+preflight and build (parallel) -> test (matrix) -> collect.
#
# The plan job enumerates the CRAN reverse dependencies of this package (to
# any depth), weighs each one by what its check is expected to cost here --
# what the last runs measured, or the time CRAN's own Linux check machine
# spends on it, scaled to this one -- decides which CRAN-baseline results from
# an earlier run are still valid, and deals the packages into cost-balanced
# shards: the heaviest round-robin first, everything else placed where it
# shares the most dependencies. The shard count follows the parallel capacity,
# because only max-parallel shards ever run at once and every shard past that
# pays another setup without starting any earlier (see revdep2/README.md for
# the algorithm and its trade-offs).
#
# The build job compiles the dev version once into the binary artifact every
# shard installs; it needs only the checkout, so it runs in parallel with
# planning. The same job then preflights: it installs and loads the whole
# dependency universe -- every package any revdep needs -- so broken dependencies surface
# before the first check minute is spent, and the pak cache the shards restore
# is warm. Its library is published too, and unpacked rather than rebuilt
# twice over: every shard takes this run's preflight library, and the plan
# walks earlier runs youngest first for whatever that leaves -- until their
# libraries cover everything this run installs, or the history runs out. pak
# still runs over the whole set afterwards, because CRAN moves between runs
# and what changed has to be built after all.
#
# Each test shard installs its dependency union, then checks every one of its
# packages against the CRAN version and the dev version at the same time --
# two `R CMD check` runs side by side against library stacks differing in that
# one package -- and compares the pair, revdepcheck-style, one manifest line
# and a pair of rcmdcheck results per package. A broken package is a result,
# never a job failure; the shard defers what its deadline cannot fit and says
# so.
#
# The collect job folds every shard artifact (and, on a retry, the untouched
# results of the run being retried) into revdepcheck-style reports (README.md,
# problems.md, failures.md, cran.md), a machine-readable manifest, and the
# baseline artifact future runs reuse to skip unchanged CRAN checks.
#
# Results land in artifacts:
# revdep2-report the merged report -- fetch with
# `gh run download <run-id> --name revdep2-report`
# or ./.github/workflows/revdep2/fetch.sh <run-id>
# revdep2-baseline old-version results, reused by later runs when the
# revdep's version, our CRAN version, the R series and the
# resolved dependency versions all still match (and the
# result is younger than baseline-max-age-days)
# revdep2-lib the preflight's installed dependency library, unpacked by
# later runs to skip rebuilding what has not changed
# revdep2-lib-index what is in that library, so a plan can decide which runs
# to take it from without downloading it
# revdep2-timings what the checks and the shards actually cost, which is how
# the next plan sizes its shards
#
# To re-check only what a run could not declare ok, dispatch again with
# `retry-run: <run-id>`; the new report carries the old run's good results
# over, so it is complete again.
#
# Dispatch-only, deliberately: nothing here runs on push. `ref` checks any
# branch, tag or commit SHA (the dispatch itself can only target a branch or
# tag, and the tree must contain these scripts), `depth` widens the net to
# transitive reverse dependencies, and `dry-run` stops after planning. Check
# results never turn the run red -- the summary and the report artifact are
# the deliverable; only infrastructure failures fail jobs.
on:
workflow_dispatch:
inputs:
ref:
description: "Branch, tag, or commit SHA to check (the tree must contain the revdep2 scripts); default: the dispatched ref"
type: string
default: ""
packages:
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:
description: "Which reverse dependencies to enumerate"
type: choice
options:
- strong
- most
default: strong
depth:
description: "Levels of reverse dependencies: 1 = direct, 2 = also revdeps of revdeps, ..., 'all' = the full transitive closure"
type: string
default: "1"
retry-run:
description: "Run id of an earlier revdep2 run; re-check only its not-ok packages"
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
default: ""
shard-budget-minutes:
description: "Check-time target per shard; smaller buys wall clock with more shards, up to one wave of max-parallel"
type: string
default: ""
max-parallel:
description: "Shards to run concurrently, and so the wave size the plan cuts: set it to the concurrency the account really has, never more (GitHub queues past its own limit anyway)"
type: string
default: ""
refresh-baseline:
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: ""
not-cran:
description: "Run the tests CRAN skips: sets NOT_CRAN=true, so skip_on_cran() does not skip (slower, noisier, and includes packages' own spelling tests)"
type: boolean
default: false
dry-run:
description: "Plan only: report the shards and reuse decisions, start no checks"
type: boolean
default: false
name: revdep2
# GitHub does not show a run the inputs it was dispatched with, so the two
# that decide how much work the run is -- how wide the net is cast, and how
# deep -- go into the run's own title, where the run list and every link to it
# carry them. "Was that depth 2 or the whole closure?" is otherwise only
# answerable by reading the plan job's log.
run-name: "revdep2 ${{ inputs.packages && format('({0})', inputs.packages) || format('({0}, depth {1})', inputs.which || 'strong', inputs.depth || '1') }}${{ inputs['retry-run'] && format(', retry of {0}', inputs['retry-run']) || '' }}${{ inputs['dry-run'] && ', dry run' || '' }}"
# Read-only by default; every job opts back into what it actually needs.
# Note that for a pull request from a fork GitHub caps the token at read-only
# regardless of what is requested here.
permissions:
contents: read
# One planning pass per checked ref at a time; a queued run waits rather than
# killing shards mid-check, because a killed shard's unfinished packages would
# have to be re-planned and re-checked.
concurrency:
group: revdep2-${{ inputs.ref || github.ref }}
cancel-in-progress: false
env:
REVDEP2_PACKAGES: ${{ inputs.packages || '' }}
REVDEP2_WHICH: ${{ inputs.which || 'strong' }}
REVDEP2_DEPTH: ${{ inputs.depth || '1' }}
REVDEP2_RETRY_RUN: ${{ inputs.retry-run || '' }}
REVDEP2_PART: ${{ inputs.part || '' }}
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
# one wave of REVDEP2_MAX_PARALLEL shards ever hits it, and then it decides
# 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_DRY_RUN: ${{ inputs.dry-run && '1' || '' }}
# Whether the shards behave like CRAN's own check machines or like a
# developer's. `skip_on_cran()` reads `NOT_CRAN`, and `r-lib/actions/setup-r`
# sets it to `true` -- which is why packages' own `tests/spelling.R` runs
# here at all. CRAN-like is the default: the point of this workflow is to
# find what a released igraph would break, and a test CRAN never runs cannot
# break on CRAN. Dispatch with `not-cran: true` to widen the net again.
REVDEP2_NOT_CRAN: ${{ inputs.not-cran && 'true' || 'false' }}
# 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
# no longer trusted against the current runner image.
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. 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 || '20' }}
REVDEP2_DEADLINE_MINUTES: ${{ vars.REVDEP2_DEADLINE_MINUTES || '300' }}
jobs:
# Planning and the preflight are one job.
#
# They were two, and the second did nothing the first had not already paid
# for: a runner, a checkout, `setup-r`, a pak install -- and then downloaded
# the plan artifact the first had just uploaded, only to read it back. That
# is about two minutes of a three-hour run, which is not the point; the point
# is that planning is 20 seconds of work wearing a whole job's overhead, and
# it sits on the critical path, because the preflight cannot start until it
# ends and every shard waits on the preflight.
#
# Merging them costs one thing, and it has to be bought back explicitly: a
# preflight failure used to be survivable, because the plan's outputs -- the
# shard matrix among them -- were already safe in a job that had succeeded.
# In one job a failing preflight step would take the matrix with it and the
# run would have nothing to check. So the plan's outputs are set before the
# preflight step runs, and the preflight step is `continue-on-error`: it
# shows as failed, the summary says what it could not install, and the shards
# go ahead and install those packages themselves. Which is exactly what the
# preflight is: an optimization, never a gate.
plan:
runs-on: ubuntu-26.04
# The preflight installs the whole dependency universe; when that goes
# wrong it tends to go wrong slowly. Below the 6 h ceiling so it ends as a
# failure with logs rather than as a hard kill.
timeout-minutes: 300
name: "Plan shards and preflight dependencies"
outputs:
# Fallbacks cover a planning step that did not run at all: `test` is
# guarded by `shards != '0'`, but its `strategy` must still parse.
matrix: ${{ steps.plan.outputs.matrix || '{"shard":["none"]}' }}
shards: ${{ steps.plan.outputs.shards || '0' }}
packages: ${{ steps.plan.outputs.packages || '0' }}
max_parallel: ${{ steps.plan.outputs.max_parallel || '1' }}
baseline_run: ${{ steps.plan.outputs.baseline_run || '0' }}
plan_hash: ${{ steps.plan.outputs.plan_hash || 'none' }}
permissions:
contents: read
# To find the newest earlier run with a baseline artifact and read its
# manifest, to read the retried run's report, and to download the
# prebuilt library artifacts of the runs the plan picked.
actions: read
steps:
- name: Check out the ref under test
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Set up R
uses: r-lib/actions/setup-r@v2
with:
use-public-rspm: true
# callr (and the processx it brings) is what puts a clock on the calls
# that have none of their own: pak's install runs in a callr child that
# can be killed, and the load test runs under a processx timeout. pak
# vendors its own copy of both and exports neither, so they are
# installed here in their own right. The planning step needs only
# jsonlite, but it is one install either way.
- name: Install pak, jsonlite and callr
run: |
install.packages("pak", repos = sprintf("https://r-lib.github.io/p/pak/stable/%s/%s/%s", .Platform$pkgType, R.Version()$os, R.Version()$arch))
install.packages(c("jsonlite", "callr"))
shell: Rscript {0}
- name: Plan shards
id: plan
env:
GH_TOKEN: ${{ github.token }}
OUT: ${{ runner.temp }}/plan.json
run: |
Rscript ./.github/workflows/revdep2/plan.R
shell: bash
# Before anything can fail: the shards are planned, and the plan is
# published whatever the preflight goes on to do.
- name: Upload the shard plan
uses: actions/upload-artifact@v6
if: steps.plan.outputs.shards != '0'
with:
name: revdep2-plan
path: ${{ runner.temp }}/plan.json
retention-days: 30
overwrite: true
# ---- preflight ----------------------------------------------------
# Everything below is skipped by a dry run, which is what makes
# `dry-run` "plan only" now that there is no separate job to skip.
# A runner ships with ~25 GB of toolchains this job will never use, and
# a dependency universe of a few thousand packages -- sources, binaries,
# build directories and the installed library at once -- is the one job
# here that can plausibly fill the disk. Reclaiming the space is cheap
# insurance; printing what is left makes the next failure diagnosable
# instead of a bare exit 143.
- name: Make room for the dependency universe
if: steps.plan.outputs.shards != '0' && inputs.dry-run != true
uses: cynkra/cynkratemplate/.github/actions/revdep2-disk-room@main
# The preflight downloads every dependency binary once; saving the pak
# cache under the plan's hash hands the shards a warm start.
- name: Cache the pak package cache
if: steps.plan.outputs.shards != '0' && inputs.dry-run != true
uses: actions/cache@v5
with:
path: ~/.cache/R/pkgcache
key: revdep2-pak-${{ steps.plan.outputs.plan_hash }}
restore-keys: |
revdep2-pak-
- name: Install and load every dependency
# The plan is already uploaded and this job's outputs are already set,
# so a preflight that dies costs the run its warm start and nothing
# else: every shard installs what it needs either way. Failing the job
# here would take the shard matrix with it.
continue-on-error: true
if: steps.plan.outputs.shards != '0' && inputs.dry-run != true
uses: cynkra/cynkratemplate/.github/actions/revdep2-install-universe@main
with:
gh-token: ${{ github.token }}
plan: ${{ runner.temp }}/plan.json
out-dir: ${{ runner.temp }}/preflight
lib-out: ${{ runner.temp }}/lib
lib-index-out: ${{ runner.temp }}/lib-index
resource-log: ${{ runner.temp }}/preflight/resources.log
tmpdir: ${{ runner.temp }}/tmp
tmp: ${{ runner.temp }}/tmp
temp: ${{ runner.temp }}/tmp
resource-phase-file: ${{ runner.temp }}/preflight/phase
# Runs even when the install step died, because "how much disk and
# memory were left" is the first question about a job that was killed
# rather than failed. It cannot run when the *runner* died -- hence the
# sampler above -- but it is what answers the question in every case
# where the job merely failed.
- name: Report what the install consumed
if: always() && steps.plan.outputs.shards != '0' && inputs.dry-run != true
run: |
./.github/workflows/revdep2/watch-resources.sh once "after the job"
./.github/workflows/revdep2/watch-resources.sh oom
du -sh ~/.cache/R/pkgcache "${RUNNER_TEMP}/lib" 2>/dev/null || true
shell: bash
- name: Upload the preflight report
uses: actions/upload-artifact@v6
if: always() && steps.plan.outputs.shards != '0' && inputs.dry-run != true
with:
name: revdep2-preflight
path: ${{ runner.temp }}/preflight
if-no-files-found: ignore
retention-days: 30
overwrite: true
# The library the next runs unpack. It is the big one -- a whole
# 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.
- name: Upload the preflight library
uses: actions/upload-artifact@v6
if: always() && steps.plan.outputs.shards != '0' && inputs.dry-run != true
with:
name: revdep2-lib
path: ${{ runner.temp }}/lib
if-no-files-found: ignore
retention-days: ${{ env.REVDEP2_PREBUILT_MAX_AGE_DAYS }}
overwrite: true
- name: Upload the library index
uses: actions/upload-artifact@v6
if: always() && steps.plan.outputs.shards != '0' && inputs.dry-run != true
with:
name: revdep2-lib-index
path: ${{ runner.temp }}/lib-index
if-no-files-found: ignore
retention-days: 30
overwrite: true
build:
# No needs: the binary depends only on the checkout, so this runs in
# parallel with planning and costs no wall clock of its own. Only the
# shards consume its artifact; a dry run skips it.
if: inputs.dry-run != true
runs-on: ubuntu-26.04
name: "Build the dev binary"
permissions:
contents: read
steps:
- name: Check out the ref under test
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Install R and the package dependencies
uses: cynkra/cynkratemplate/.github/actions/install@main
with:
token: ${{ secrets.GITHUB_TOKEN }}
cache-version: revdep2-1
needs: build
- name: Build the binary
env:
OUT_DIR: ${{ runner.temp }}/pkg
run: |
Rscript ./.github/workflows/revdep2/build.R
shell: bash
- name: Upload the package binary
uses: actions/upload-artifact@v6
with:
name: revdep2-pkg
path: ${{ runner.temp }}/pkg
retention-days: 30
overwrite: true
test:
needs:
- plan
- build
# The preflight is an optimization and an early diagnosis, not a
# prerequisite: a shard installs its own dependency union anyway, and that
# union is a fraction of the universe the preflight takes on (~500 of 4400
# in the run that made this necessary). So a preflight that dies must not
# take the checks with it -- which is now a `continue-on-error` on the
# preflight step itself, since it shares a job with the planning that
# produces this matrix. `!cancelled()` still lets this job run past a
# failed `needs`, and the dry-run guard is spelled out because nothing
# upstream is skipped by it any more.
if: >-
!cancelled()
&& needs.plan.result == 'success'
&& needs.build.result == 'success'
&& needs.plan.outputs.shards != '0'
&& inputs.dry-run != true
runs-on: ubuntu-26.04
# Below GitHub's 6 h ceiling, and above the shard's own deadline: the shard
# is meant to stop itself and defer what is left, not to be killed
# mid-check.
timeout-minutes: 350
name: "shard ${{ matrix.shard }} (${{ matrix.label }})"
permissions:
contents: read
# To download the baseline and prebuilt-library artifacts, which live on
# earlier runs.
actions: read
strategy:
fail-fast: false
max-parallel: ${{ fromJSON(needs.plan.outputs.max_parallel) }}
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
env:
R_REMOTES_NO_ERRORS_FROM_WARNINGS: true
# prevent rgl issues because no X11 display is available
RGL_USE_NULL: true
# CRAN-like check conditions for the revdeps, not the incoming gauntlet
_R_CHECK_CRAN_INCOMING_: false
_R_CHECK_SYSTEM_CLOCK_: false
_R_CHECK_FUTURE_FILE_TIMESTAMPS_: false
_R_CHECK_FORCE_SUGGESTS_: false
# `--as-cran` turns on `--run-donttest`, which is the most expensive
# thing a check does and the least useful thing for this workflow:
# `\donttest{}` is where packages put the examples too slow to run on
# CRAN, so it is where the runners spend hours and where the timeouts
# land -- varPro's old half was killed at 1200s in "checking examples
# with --run-donttest". `\dontrun{}` is off unless asked for, and stays
# off. What is left is every example a package expects to run.
_R_CHECK_DONTTEST_EXAMPLES_: false
# No spell checking. It cannot tell us anything about igraph -- a
# misspelling in a revdep's DESCRIPTION is the same misspelling in both
# halves -- and it is noise in a log that is read to find real
# differences. `_R_CHECK_CRAN_INCOMING_: false` already suppresses it;
# this says so out loud so `--as-cran` cannot turn it back on.
_R_CHECK_CRAN_INCOMING_USE_ASPELL_: false
# No timings in the check output.
#
# `--as-cran` sets `_R_CHECK_TIMINGS_` to 10, which stamps every stage
# slower than that with its own `[user/elapsed]` pair and appends an
# "Examples with CPU ... > 5s" table. Both are pure noise here: they
# differ between the two halves by construction -- they are wall clock,
# measured on two checks racing each other for the same four cores --
# so every one of them is a line in the diff that says nothing about the
# package. `neutral_log()` strips them so the *comparison* is not fooled;
# this stops them being produced at all, so the diff a human reads is
# only what changed.
#
# Nothing is lost. What a stage cost is still recorded, per line and for
# every stage rather than only the slow ones, by the elapsed stamping in
# check-pair.sh -- which is on the driver log, not on the file the halves
# are compared through.
_R_CHECK_TIMINGS_: ""
_R_CHECK_EXAMPLE_TIMING_THRESHOLD_: 99999
# 300 lines of a failed test transcript in the check log, not R's default
# 13 -- thirteen routinely cuts off the failure itself, which is both
# what a reader wants and the part the old/new diff has to see to be
# worth printing. Not unlimited, because this text is carried in the
# check log, in the diff and in the summary, and one chatty test would
# otherwise bury the rest of the shard in all three. Nothing is lost by
# bounding it: R writes the *complete* transcript to `<file>.Rout.fail`
# whatever this is set to, and the shard keeps that file.
_R_CHECK_TESTS_NLINES_: 300
# What the driver reads in both of its phases and does not need the
# runner for. The `runner` context is not available this far up -- only
# in a step -- so the directories are set once in `$GITHUB_ENV` below.
GH_TOKEN: ${{ github.token }}
SHARD: ${{ matrix.shard }}
TIMEOUT_FACTOR: ${{ vars.REVDEP2_TIMEOUT_FACTOR || '1.5' }}
TIMEOUT_MIN_MINUTES: ${{ vars.REVDEP2_TIMEOUT_MIN_MINUTES || '20' }}
DEADLINE_MINUTES: ${{ vars.REVDEP2_DEADLINE_MINUTES || '300' }}
PKG_SYSREQS: true
steps:
- name: Check out the ref under test
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Set up pandoc
uses: r-lib/actions/setup-pandoc@v2
- name: Close the log group that setup-pandoc leaves open
# See `.github/actions/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
- name: Set up TinyTeX
uses: r-lib/actions/setup-tinytex@v2
- name: Install system tools for checking
run: |
sudo apt-get update -y
sudo apt-get install -y qpdf ghostscript
shell: bash
- name: Install pak
run: |
install.packages("pak", repos = sprintf("https://r-lib.github.io/p/pak/stable/%s/%s/%s", .Platform$pkgType, R.Version()$os, R.Version()$arch))
install.packages(c("jsonlite", "rcmdcheck"))
shell: Rscript {0}
- 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-
- name: Download the shard plan
uses: actions/download-artifact@v7
with:
name: revdep2-plan
path: ${{ runner.temp }}/plan
- name: Download the package binary
uses: actions/download-artifact@v7
with:
name: revdep2-pkg
path: ${{ runner.temp }}/pkg
# This run's own preflight library: the shard unpacks it instead of
# 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.
- name: Download this run's preflight library
uses: actions/download-artifact@v7
continue-on-error: true
with:
name: revdep2-lib
path: ${{ runner.temp }}/lib
# The baseline lives on an earlier run; absence is not an error, the
# shard just checks the CRAN version fresh.
- name: Download the baseline results
uses: actions/download-artifact@v7
if: needs.plan.outputs.baseline_run != '0'
continue-on-error: true
with:
name: revdep2-baseline
run-id: ${{ needs.plan.outputs.baseline_run }}
github-token: ${{ github.token }}
path: ${{ runner.temp }}/baseline
# Both driver steps need these and neither can name them itself: the
# `runner` context does not reach the job's `env`, so they are set here
# for every step that follows.
#
# `NOT_CRAN` is here rather than in the job's `env` because
# `r-lib/actions/setup-r` writes `NOT_CRAN=true` into `$GITHUB_ENV`, and
# a later write is what reliably overrides an earlier one. This step runs
# after it and before both driver steps.
- name: Point the driver at this runner's directories
uses: cynkra/cynkratemplate/.github/actions/revdep2-driver-env@main
with:
not-cran-input: ${{ env.REVDEP2_NOT_CRAN }}
# Two steps, one driver. The install is minutes to an hour and the checks
# are hours, and as one step the run page could only report their sum --
# so "shard 14 took five hours" said nothing about whether it spent them
# unpacking dependencies or checking packages, and the install times turn
# out to vary by a lot between shards. Split, each is timed by Actions
# itself and readable at a glance from the job page.
#
# The phases share the job's environment and the work directory; the
# install leaves the libraries and a note of what it cost behind, and the
# check picks both up. Nothing is done twice.
# The same sampler the preflight runs, for the same reason, and the shard
# needed it more.
#
# Shard 16 of run 31951756102 died with "The hosted runner lost
# communication with the server", whose own advice names starvation of
# CPU, memory or network as the causes -- and there was not one number
# from that job to say which, because a runner that stops answering takes
# its `if: always()` steps with it. Only what was already streamed to the
# log survives, so the numbers have to be emitted while the work runs.
#
# Backgrounded once here rather than per step: a process started in one
# step outlives it, and Actions reaps it with the job. `RESOURCE_LOG` also
# puts the series in the shard artifact, for the jobs that do reach their
# upload.
- name: Start the resource sampler
run: |
watch=./.github/workflows/revdep2/watch-resources.sh
"${watch}" once "shard ${SHARD} before the install"
RESOURCE_LOG="${RUNNER_TEMP}/results/resources.log" \
"${watch}" watch 30 "shard ${SHARD}" &
disown || true
shell: bash
- name: Install packages
env:
PHASE: install
run: |
Rscript ./.github/workflows/revdep2/shard.R
shell: bash
# The checks run in three slices, each followed by an upload.
#
# The driver has always written its results as it goes, so that a shard
# killed part way through still accounts for what it finished -- but the
# upload was one step at the end, and `if: always()` cannot save a step on
# a runner that is no longer there. Shard 16 of run 31951756102 got three
# minutes into a 196-minute check budget before
#
# ##[error]The runner has received a shutdown signal.
# ##[error]Process completed with exit code 143.
#
# took the whole job with it. The upload was skipped and all 87 packages
# were reported `missing`, having cost a runner an hour. At three slices
# that is bounded to a third of the shard, and the interim artifacts also
# say `deferred` for the packages not yet reached, which is truer than the
# `missing` the collector would otherwise reconcile them into.
#
# Three rather than more because each upload is a step, and the artifact
# carries the whole results directory every time; the shard's own deadline
# already caps the tail.
#
# `!cancelled()` rather than the default `success()`: an install step that
# *failed* still leaves a shard full of packages to account for, and the
# driver reports a package it could not install as a depfail, which is a
# result. Skipping this step instead left all 50 of shard 3's packages as
# `missing` in run 31893156685 -- the one outcome that tells nobody
# anything. The later slices additionally tolerate an earlier one having
# failed, for the same reason.
- name: Check the shard (1 of 3)
if: '!cancelled()'
env:
PHASE: check
CHECK_SLICE: 1/3
run: |
Rscript ./.github/workflows/revdep2/shard.R
shell: bash
# 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. `overwrite` so the
# slices replace one another under the one name -- they share a results
# directory, so the newest upload is a superset of the ones before it.
- name: Upload the shard results (1 of 3)
uses: actions/upload-artifact@v6
if: always()
with:
name: revdep2-results-${{ matrix.shard }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/results
if-no-files-found: ignore
overwrite: true
retention-days: 30
- name: Check the shard (2 of 3)
if: '!cancelled()'
env:
PHASE: check
CHECK_SLICE: 2/3
run: |
Rscript ./.github/workflows/revdep2/shard.R
shell: bash
- name: Upload the shard results (2 of 3)
uses: actions/upload-artifact@v6
if: always()
with:
name: revdep2-results-${{ matrix.shard }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/results
if-no-files-found: ignore
overwrite: true
retention-days: 30
- name: Check the shard (3 of 3)
if: '!cancelled()'
env:
PHASE: check
CHECK_SLICE: 3/3
run: |
Rscript ./.github/workflows/revdep2/shard.R
shell: bash
# Before the final upload, so the last sample and the OOM verdict ride in
# the artifact. It cannot run when the *runner* died -- hence the sampler
# started before the install -- but it is what answers "was it memory?" in
# every case where the job merely failed.
- name: Report what the shard consumed
if: always()
run: |
./.github/workflows/revdep2/watch-resources.sh once "shard ${SHARD} after the checks"
./.github/workflows/revdep2/watch-resources.sh oom
df -BG / "${RUNNER_TEMP}" 2>/dev/null || true
shell: bash
- name: Upload the shard results
uses: actions/upload-artifact@v6
if: always()
with:
name: revdep2-results-${{ matrix.shard }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/results
if-no-files-found: ignore
overwrite: true
retention-days: 30
collect:
needs:
- plan
- build
- test
# `always()` so a run with red shards still reports -- a broken revdep is
# the result this workflow exists to surface. Guarded on the plan and the
# binary, because without those there is nothing to collect, and on the
# dry-run input, which is now the only thing that stops this job from
# reporting: the preflight is a step of the plan job and cannot fail it, so
# a run whose preflight died still has shards, results, and packages to
# account for.
if: >-
always()
&& needs.plan.result == 'success'
&& needs.build.result == 'success'
&& needs.plan.outputs.shards != '0'
&& inputs.dry-run != true
runs-on: ubuntu-26.04
name: "Collect results and report"
permissions:
# 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:
- name: Check out the ref under test
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Set up R
uses: r-lib/actions/setup-r@v2
with:
use-public-rspm: true
- name: Install pak and jsonlite
run: |
install.packages("pak", repos = sprintf("https://r-lib.github.io/p/pak/stable/%s/%s/%s", .Platform$pkgType, R.Version()$os, R.Version()$arch))
install.packages("jsonlite")
shell: Rscript {0}
# The reports come out of revdepcheck itself; when this fails the
# collector still writes a manifest-derived summary.
- name: Install revdepcheck
continue-on-error: true
env:
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
run: |
pak::pkg_install("krlmlr/revdepcheck")
shell: Rscript {0}
- name: Download the shard plan
uses: actions/download-artifact@v7
with:
name: revdep2-plan
path: ${{ runner.temp }}/plan
# 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.
- name: Download the results of all shards
uses: actions/download-artifact@v7
continue-on-error: true
with:
pattern: revdep2-results-*
path: ${{ runner.temp }}/results
- name: Download the report of the run being retried
uses: actions/download-artifact@v7
if: env.REVDEP2_RETRY_RUN != ''
continue-on-error: true
with:
name: revdep2-report
run-id: ${{ env.REVDEP2_RETRY_RUN }}
github-token: ${{ github.token }}
path: ${{ runner.temp }}/retry-report
- name: Collect results
id: collect
env:
# To read how long the shard *jobs* took -- the minutes before their
# driver starts, which is what an extra shard really costs.
GH_TOKEN: ${{ github.token }}
PLAN: ${{ runner.temp }}/plan/plan.json
RESULTS_DIR: ${{ runner.temp }}/results
RETRY_DIR: ${{ runner.temp }}/retry-report
OUT_DIR: revdep
BASELINE_OUT: ${{ runner.temp }}/baseline
TIMINGS_OUT: ${{ runner.temp }}/timings
run: |
Rscript ./.github/workflows/revdep2/collect.R
shell: bash
- name: Upload the report
uses: actions/upload-artifact@v6
if: always()
with:
name: revdep2-report
path: revdep
retention-days: 90
overwrite: true
# 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.
# `!cancelled()` rather than `always()`: a cancelled run has no shard
# results, so its report is every package `missing`. Run 31303054725 was
# cancelled and pushed 104k lines of that onto the branch it was testing.
# A run with red shards is a different thing and still reports.
#
# `compared > 0` covers the rest of that family: a run in which *no*
# package produced a comparison learnt nothing, and the committed report
# is the repository's record and what `packages: broken` reads back. The
# report is still built and uploaded; only overwriting the good one is
# skipped.
- name: Commit the report to the checked ref
if: >-
!cancelled()
&& vars.REVDEP2_COMMIT_REPORT != 'false'
&& steps.collect.outputs.compared != '0'
continue-on-error: true
uses: cynkra/cynkratemplate/.github/actions/revdep-commit-report@main
with:
ref: ${{ inputs.ref }}
- name: Upload the new baseline
uses: actions/upload-artifact@v6
if: always()
with:
name: revdep2-baseline
path: ${{ runner.temp }}/baseline
retention-days: 90
overwrite: true
# 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.
- name: Upload the measured timings
uses: actions/upload-artifact@v6
if: always()
with:
name: revdep2-timings
path: ${{ runner.temp }}/timings
if-no-files-found: ignore
retention-days: 90
overwrite: true