Repository navigation
1265 lines (1201 loc) · 61.6 KB
/
Copy pathcheck.yml
File metadata and controls
1265 lines (1201 loc) · 61.6 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
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Rust compilation shares one target directory; independent gates run beside it.
# All lanes use scripts/test.sh, whose default still runs the complete gate.
name: check
on:
push:
branches: [main]
pull_request:
# A merge queue tests each pull request on top of the main it will land on.
# Two pull requests green against their own bases can still break main
# together, as #401 and #410 did; nothing else here runs them side by side.
merge_group:
# The avatar check needs a real model download, so it runs weekly rather
# than on every change. It is the only job a schedule or a dispatch starts.
schedule:
- cron: '17 5 * * 1'
workflow_dispatch:
permissions:
contents: read
# A second push to a branch makes the first run's answer worthless, so stop
# paying for it. Never on main: those runs are the record of what passed.
#
# The event is in the group so a weekly avatar run on main never queues behind,
# or displaces, the push run that publishes a release.
concurrency:
group: check-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
# Every build here is a link-bound build: eleven test binaries between 29 and
# 84 MB, each statically linking libwebrtc. Two settings attack that, and both
# pay twice because the mutation gate rebuilds once per mutant.
#
# Debug info is the larger half and was measured: touching one file and
# relinking the suite took 16.6s with full debug and 9.9s with
# line-tables-only, a 40% cut per mutant. `line-tables-only` rather than `0`
# keeps file and line in a CI backtrace, which is the only part of debug info
# a failed run here actually uses.
#
# mold replaces the linker itself. It is the tool built for this shape of
# problem, many large static inputs, and it is an apt package on this image.
#
# Both change the build fingerprint, which is why the cargo cache key hashes
# them (restore-cargo-cache says how). Without that, a cache saved under the
# old flags would be restored forever and rebuilt from scratch every run,
# which is worse than having no cache at all.
CARGO_PROFILE_DEV_DEBUG: line-tables-only
CARGO_PROFILE_TEST_DEBUG: line-tables-only
RUSTFLAGS: -Clink-arg=-fuse-ld=mold
# One source for the pin: the release tag and the source install it falls
# back to, in .github/actions/install-mutants.
CARGO_MUTANTS_VERSION: 27.1.0
# Pinned for the same reason as the one above: a linter that moves under you
# turns an unrelated push red, and the fix is then a version bump nobody chose
# to make. Kept in step with whatever contributors run locally, which is
# whatever `ruff` is on their PATH; the gate skips rather than fails when they
# have none.
RUFF_VERSION: 0.15.2
# The workflow's own linter, pinned like the tools above. Installed rather
# than assumed: `scripts/test.sh` skips this gate when the binary is absent,
# and absent is what it was here, so the file that describes every other
# check was the one file nothing checked.
ACTIONLINT_VERSION: 1.7.12
# scripts/hook-check would install git hooks into each runner's checkout and
# fail the build over them. Nobody commits here, and the commit-log step
# replays every message the hooks would have judged.
CODETRIAL_SKIP_HOOKS: 1
# The license and source policy in deny.toml, pinned like the tools above:
# a release that reads that file differently should arrive as a bump.
CARGO_DENY_VERSION: 0.20.2
jobs:
audit:
if: github.event_name != 'schedule' && github.event_name != 'workflow_dispatch'
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 2
persist-credentials: false
- name: Install cargo-audit
uses: ./.github/actions/install-audit
- name: Audit the tested merge or main
env:
EVENT: ${{ github.event_name }}
GROUP_BASE: ${{ github.event.merge_group.base_sha }}
run: |
case "$EVENT" in
pull_request)
base=$(git rev-parse HEAD^1)
python3 scripts/audit.py --base "$base"
;;
merge_group)
git cat-file -e "$GROUP_BASE^{commit}" || git fetch --no-tags --depth=1 origin "$GROUP_BASE"
python3 scripts/audit.py --base "$GROUP_BASE"
;;
push) python3 scripts/audit.py ;;
*) exit 2 ;;
esac
gates:
name: gates (${{ matrix.lane }}${{ matrix.shard && format(' {0}', matrix.shard) || '' }})
if: github.event_name != 'schedule' && github.event_name != 'workflow_dispatch'
strategy:
fail-fast: false
matrix:
# What each lane installs, so a gate never skips for a missing tool.
# sqlite3 is for a Rust test of the retention script; shellcheck and
# shfmt are the shell gates' optional tools. Only the Rust lane links,
# so only it installs mold for the workflow's RUSTFLAGS.
#
# `timeout` is each lane's wedge detector: against the six-hour default
# it catches a job that has stopped making progress, which is all it is
# for. The browser lane gets far less than the cargo build, because
# Node bounds each test file too, but a job can also stall during
# dependency installation before the test runner starts.
include:
- lane: rust
timeout: 45
# One apt transaction for both crates; all desktop dependencies
# remain required even when a change only touches the server.
packages: libglib2.0-dev mold sqlite3 libwebkit2gtk-4.1-dev libxdo-dev libssl-dev librsvg2-dev
- lane: portable
timeout: 20
packages: shellcheck shfmt
# Browser suites are the critical path: with two slices the slower
# one spent 374s in the gates. Three runners keep the per-runner
# concurrency bounded while spreading independent files.
# BROWSER_SHARD partitions by size (see scripts/test.sh).
- lane: browser
shard: 1/3
timeout: 20
packages: ffmpeg
- lane: browser
shard: 2/3
timeout: 20
packages: ffmpeg
- lane: browser
shard: 3/3
timeout: 20
packages: ffmpeg
# Pinned rather than `ubuntu-latest`: that label moves to a new image on
# GitHub's schedule, which turns an unrelated morning into a debugging
# session. Bumping this is a commit somebody chose to make.
runs-on: ubuntu-24.04
timeout-minutes: ${{ matrix.timeout }}
steps:
# A pull request follows its head branch, so one opened from a fork's
# main takes in every later push there, and the contributor cannot start
# a second change until it merges. The pre-push hook refuses that push;
# this refuses the pull request of a contributor who never installed the
# hooks. Ahead of the checkout, because it needs nothing from the tree.
# The branch name is read in the condition only and never reaches a
# shell, because a fork chooses it.
- name: Refuse a pull request from a fork's main
if: >-
github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name != github.repository
&& github.event.pull_request.head.ref == 'main'
run: |
echo "This pull request is opened from the main branch of a fork." >&2
echo "Push the commits to a topic branch and open a new pull request from it." >&2
exit 1
# The commit-message check needs every commit in the pull request. The
# other lanes read only HEAD, which build.rs records, so they skip the
# history.
- uses: actions/checkout@v7
with:
fetch-depth: ${{ matrix.lane != 'portable' && 1 || 0 }}
persist-credentials: false
# The commit-msg hook binds whoever installed it. This binds everyone
# else: a rebase, an amend, `--no-verify`, or a subject typed into the
# GitHub merge box all reach the branch unread otherwise. It runs the same
# script the pre-push hook runs, so a contributor with hooks and a
# contributor without are judged by one list.
#
# Pull requests only, and deliberately. `base.sha..HEAD` is the work being
# proposed, which is the thing these rules are about. The push event's
# range is not: a merge to main replays every commit of the merged branch,
# so a rule this log only started following recently would reject history
# rather than the change in front of it.
#
# First among the steps that read the tree, because it costs a second and
# the rest of this job costs minutes.
- name: Check the commit messages
if: github.event_name == 'pull_request' && matrix.lane == 'portable'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
# An unfetched base is not an empty range. Say which one this is
# rather than reporting "nothing to check" for a check that could not
# run.
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
echo "base commit $BASE_SHA is not in this checkout" >&2
exit 1
fi
git rev-list "$BASE_SHA..HEAD" | python3 scripts/check-secrets.py --commits
git rev-list --no-merges "$BASE_SHA..HEAD" | ./scripts/check-commit-log.sh
- name: Verify the Change-Id hashchain
if: >-
matrix.lane == 'portable' &&
(github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'merge_group')
env:
CHAIN_TIP: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha || github.sha }}
CHAIN_POLICY_REF: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }}
CHAIN_BOOTSTRAP_HEAD: ${{ vars.CODETRIAL_HASHCHAIN_BOOTSTRAP_HEAD }}
run: |
set -eu
export GIT_NO_REPLACE_OBJECTS=1 GIT_TERMINAL_PROMPT=0
bootstrap=b6eb8ba9bfbb14ff8d8a7dee920d29590ee89f25
policy_ref=${CHAIN_POLICY_REF:-$bootstrap}
if [ "$policy_ref" = 0000000000000000000000000000000000000000 ]; then
policy_ref=$bootstrap
fi
if ! git cat-file -e "$policy_ref^{commit}" 2>/dev/null; then
echo "The policy commit $policy_ref is not in this checkout (a force-push or rewritten branch drops the previous tip)." >&2
echo "Push the branch again from a descendant of the base, or re-run on the pull request." >&2
exit 1
fi
verifier=$(mktemp)
trap 'rm -f "$verifier"' EXIT
if git cat-file -e "$policy_ref:scripts/hashchain-base" 2>/dev/null; then
anchor=$(git show "$policy_ref:scripts/hashchain-base")
git show "$policy_ref:scripts/hashchain.py" > "$verifier"
else
# Always execute the externally approved source, even when GitHub
# wraps that PR head in a new merge commit for main or the queue.
if ! printf '%s\n' "${CHAIN_BOOTSTRAP_HEAD:-}" | grep -Eq '^[0-9a-f]{40}$'; then
echo 'Hashchain bootstrap requires maintainer approval of an exact full head SHA.' >&2
exit 1
fi
if [ "$CHAIN_BOOTSTRAP_HEAD" != "$CHAIN_TIP" ]; then
parents=$(git show -s --format=%P "$CHAIN_TIP")
if [ "$parents" != "$policy_ref $CHAIN_BOOTSTRAP_HEAD" ]; then
echo 'Hashchain bootstrap requires maintainer approval of this exact head or its clean integration.' >&2
exit 1
fi
for path in scripts/hashchain.py scripts/hashchain-base; do
if [ "$(git rev-parse "$CHAIN_TIP:$path")" != "$(git rev-parse "$CHAIN_BOOTSTRAP_HEAD:$path")" ]; then
echo 'Hashchain bootstrap integration changed the approved verifier or policy.' >&2
exit 1
fi
done
fi
anchor=$bootstrap
if [ "$(git show "$CHAIN_BOOTSTRAP_HEAD:scripts/hashchain-base")" != "$anchor" ]; then
echo 'Hashchain bootstrap policy must retain the fixed initial anchor.' >&2
exit 1
fi
git show "$CHAIN_BOOTSTRAP_HEAD:scripts/hashchain.py" > "$verifier"
fi
python3 "$verifier" verify --tip "$CHAIN_TIP" --anchor "$anchor"
- name: Reclaim runner disk
if: matrix.lane == 'rust'
uses: ./.github/actions/reclaim-disk
- name: Install system packages
if: matrix.packages
uses: ./.github/actions/apt-install
with:
packages: ${{ matrix.packages }}
- name: Install WebRTC C++ compiler
if: matrix.lane == 'rust'
uses: ./.github/actions/webrtc-clang
# rust-toolchain.toml names the release, and clippy and rustfmt with it;
# this installs that one rather than whatever the runner image carries.
# The browser lane runs no cargo at all.
- name: Install Rust
if: matrix.lane != 'browser'
run: rustup toolchain install
# Restore and save are separate steps on purpose. `actions/cache` saves
# from a post-job step that is skipped unless the job succeeded, so a run
# that built the whole dependency graph and then failed one test threw the
# build away and made the next run pay for it again. That is not
# hypothetical here: nothing saved a cache between 21 and 26 August while
# runs failed on disk and were superseded by pushes, so every run in that
# window restored a five-day-old target/ and rebuilt the difference, which
# is most of why the gates took twelve minutes.
- name: Restore cargo cache
if: matrix.lane == 'rust'
id: cargo-cache
uses: ./.github/actions/restore-cargo-cache
# The desktop crate's cache key: the cargo cache's compilers and flags,
# under a name of its own and with its own lockfile.
- name: Compute the desktop cache key
if: matrix.lane == 'rust'
id: desktop
env:
CARGO_PREFIX: ${{ steps.cargo-cache.outputs.prefix }}
INPUTS: ${{ hashFiles('desktop/Cargo.lock') }}
run: |
prefix="desktop-${CARGO_PREFIX#cargo-}"
echo "prefix=$prefix" >>"$GITHUB_OUTPUT"
echo "key=$prefix$INPUTS" >>"$GITHUB_OUTPUT"
# The desktop crate's build, in an entry of its own. The cargo cache is
# also what every mutation shard restores, and a path added to it would
# be a gigabyte of WebKit bindings each shard downloads and never reads.
- name: Restore desktop cargo cache
id: desktop-cache
if: matrix.lane == 'rust'
uses: actions/cache/restore@v6
with:
path: target/desktop
key: ${{ steps.desktop.outputs.key }}
restore-keys: ${{ steps.desktop.outputs.prefix }}
# Every lane fetches and verifies these bytes; only the portable lane
# saves them, below, so three lanes do not race to write one key.
- name: Restore vendored bytes
id: vendor
uses: ./.github/actions/restore-vendor
# The npm install itself is not cached. It is 90 packages now that eslint
# is here, but they are small and pure JavaScript, and the step runs in
# seconds against a cargo build that runs in minutes. A cache round trip
# would not show up next to that.
#
# `npm ci`, not `npm install`: eslint's transitive dependencies are caret
# ranges, so an install resolves them fresh on every run and the gate that
# passed yesterday is not the gate running today. The exact pins in
# package.json only cover the four direct dependencies.
- name: Install node dependencies
run: npm ci
# Both browser-driving lanes restore Chromium; the browser lane saves it,
# below.
- name: Install Chromium
if: matrix.lane != 'portable'
id: chromium
uses: ./.github/actions/chromium
- name: Place the instructor packaging environment
if: matrix.lane == 'portable'
run: |
# Outside target/, which is cached: a restored venv would keep a
# package the requirements file has since dropped.
# The gate sets it up there itself, through the same path the
# instructor tool takes.
echo "CODETRIAL_TASK_TOOLS=$RUNNER_TEMP/task-tools" >> "$GITHUB_ENV"
# The Python here generates the problem bank and drives two integration
# harnesses, and `ruff check` is its only linter. The gate skips when the
# binary is missing, which is right on a laptop and wrong here. pipx is on
# the image and installs into its own environment, so this does not fight
# the runner's externally-managed Python.
- name: Install ruff
if: matrix.lane == 'portable'
run: pipx install "ruff==$RUFF_VERSION"
# commentflow settles comment width, which no other formatter here does:
# rustfmt leaves a short-wrapped comment short and shfmt does not touch
# comment text at all. The indent gate skips its reflow lane when the
# binary is missing, which is right on a laptop and wrong here, so it is
# installed ahead of the gate.
#
# It ships as a release binary rather than an apt package, and its only
# tag is a rolling "latest", so the version is not frozen: a republished
# "latest" is picked up silently. That is tolerable here and nowhere
# else in this job. Comment reflow is a formatting gate, so the failure
# mode is a diff in the indent check rather than a wrong binary running
# against the tree, and that diff is what surfaces it.
- name: Install commentflow
if: matrix.lane == 'portable'
env:
GH_TOKEN: ${{ github.token }}
run: >-
./scripts/install-release-binary.sh sysprog21/commentflow latest
commentflow-x86_64-unknown-linux-gnu.tar.gz commentflow
- name: Install cargo-deny
if: matrix.lane == 'rust'
env:
GH_TOKEN: ${{ github.token }}
run: >-
./scripts/install-release-binary.sh EmbarkStudios/cargo-deny
"$CARGO_DENY_VERSION"
"cargo-deny-$CARGO_DENY_VERSION-x86_64-unknown-linux-musl.tar.gz"
"cargo-deny-$CARGO_DENY_VERSION-x86_64-unknown-linux-musl/cargo-deny"
# Pinned to a tag rather than tracking a rolling one, unlike commentflow
# above: this one judges the workflow, so a linter that moves on its own
# turns an unrelated push red.
- name: Install actionlint
if: matrix.lane == 'portable'
env:
GH_TOKEN: ${{ github.token }}
run: >-
./scripts/install-release-binary.sh rhysd/actionlint
"v$ACTIONLINT_VERSION"
"actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" actionlint
# A pull request Dependabot opened and wrote has its formatting drift
# reported rather than failed (scripts/test.sh says why). The opener
# cannot be forged, unlike a commit's author; the commits are still read,
# so a person's commit on the branch is held to the drift. The range runs
# to the pull request's head rather than the merge ref, so a person's
# merge into the branch counts as theirs. Every other event gets no range
# and stays strict. Main has no merge queue yet, so nothing after this
# stops the drift landing: the indent gate reads the whole tree, and the
# push to main and every later pull request fail on it until it is fixed.
- name: Run gates
env:
BROWSER_SHARD: ${{ matrix.shard }}
INDENT_ADVISORY_RANGE: >-
${{ github.event_name == 'pull_request'
&& github.event.pull_request.user.login == 'dependabot[bot]'
&& format('{0}..{1}', github.event.pull_request.base.sha,
github.event.pull_request.head.sha) || '' }}
run: ./scripts/test.sh "${{ matrix.lane }}"
# A merge group runs on a queue ref that is deleted once it lands, and a
# cache saved there is visible to no other ref, so none of the saves below
# run for one.
#
# After the gates pass, so the bytes saved are bytes verify-vendor
# accepted. Under the restore step's key: hashing the tree now would also
# hash what the fetch added, and no later restore would ask for that key.
- name: Save vendored bytes
if: github.event_name != 'merge_group' && matrix.lane == 'portable' && steps.vendor.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: web/vendor
key: ${{ steps.vendor.outputs.key }}
- name: Save Chromium
if: github.event_name != 'merge_group' && matrix.lane == 'browser' && steps.chromium.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: ~/.cache/ms-playwright
key: ${{ steps.chromium.outputs.key }}
# The release job makes the same comparison, but only after a merge: a
# `build.rs` that stops recording the commit would pass every pull
# request and turn the publishing leg red instead. Only after the gates
# pass: a failed build leaves the binary the cache restored, and its
# version would name some earlier commit.
- name: Prove the build names its commit
if: matrix.lane == 'rust'
run: |
version=$(target/debug/codetrial --version)
head=$(git rev-parse HEAD)
[[ $version == *"(${head::7})" ]] \
|| { echo "build reports \"$version\", not ${head::7}" >&2; exit 1; }
# Its own step, not part of `cargo test`. It drives a real browser through
# the offline interview and is hermetic now, with the Compiler Explorer
# calls intercepted, but it still fails on a two minute navigation timeout
# when the machine is busy: `cargo test` is what people run while doing
# something else, and a gate that goes red for being run at a bad moment
# teaches everyone to ignore red. A dedicated runner has no such
# contention, so this is where it can be trusted.
#
# browser-check.sh exits 3 when Playwright is missing and the test skips
# on that. Chromium is installed above, so a skip here means the install
# step silently did nothing and is worth chasing.
- name: Run the browser interview check
if: matrix.lane == 'rust'
run: cargo test --locked --test web -- --ignored browser_check_accepts_running_rust_server_offline_interview
# Last, and not conditional on the gates passing. An exact key hit has
# nothing new to store; anything else is a build worth keeping whatever
# the gates decided about the code. `cancelled()` is excluded because a
# superseded run is killed mid-step and has nothing coherent to save.
#
# Main only, for these two. A cache saved on a pull request is readable
# by that pull request alone, and the two entries are 2.4 GB between
# them: against a 10 GB repository limit that was full, one lockfile
# change on a branch evicted the entries every other branch restores.
# A pull request that changes the lockfile still starts from main's tree
# through the restore key and rebuilds only what moved.
- name: Save cargo cache
if: ${{ !cancelled() && github.ref == 'refs/heads/main' && matrix.lane == 'rust' && steps.cargo-cache.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v6
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target
!target/desktop
key: ${{ steps.cargo-cache.outputs.key }}
- name: Save desktop cargo cache
if: >-
${{ !cancelled() && github.ref == 'refs/heads/main'
&& matrix.lane == 'rust'
&& steps.desktop-cache.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v6
with:
path: target/desktop
key: ${{ steps.desktop.outputs.key }}
# The one status: every gate lane and audit passed, and on a PR the mutation
# plan and every shard did too. Planning only runs on pull requests, and the
# shards only after a plan that found mutants, so a skipped shard job after a
# successful plan means the plan counted none. A push or a merge group plans
# nothing, so it is answered as soon as the lanes finish.
#
# `!cancelled()` rather than `always()`: a run superseded by a newer push has
# nothing to report, while a lane or shard that failed or timed out still
# reaches this job and fails it. Not on a schedule or a dispatch, which run
# no gates.
check:
needs: [gates, audit, plan-mutants, mutants]
if: ${{ !cancelled() && github.event_name != 'schedule' && github.event_name != 'workflow_dispatch' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Require every gate lane and mutation shard
env:
GATES: ${{ needs.gates.result }}
AUDIT: ${{ needs.audit.result }}
PLAN: ${{ needs.plan-mutants.result }}
MUTANTS: ${{ needs.mutants.result }}
run: |
if [ "$GATES" != success ]; then
echo "::error::gate lanes: $GATES. This job only collects them; the failed gates lane has the cause."
exit 1
fi
if [ "$AUDIT" != success ]; then
echo "::error::security audit: $AUDIT. See the audit job for new vulnerabilities or an incomplete scan."
exit 1
fi
case "$MUTANTS/$PLAN" in
success/success | skipped/success | skipped/skipped) ;;
*) echo "::error::mutants: $MUTANTS (plan: $PLAN)"; exit 1 ;;
esac
# How many shards the diff is worth, decided before any of them start.
#
# A fixed width optimises for the outlier and taxes the common case: four
# runners each paying six minutes of setup to test two mutants is worse than
# one runner testing eight. `--list` parses the tree without building it, one
# second here, so the count is cheap to ask for and the width can follow it.
#
# One shard per 15 mutants, under the ceiling the step explains. The ceiling
# is not about arithmetic: past it the setup cost of another runner stops
# paying for itself against the mutants it would take.
#
# A pull request from a fork's main is refused in `gates`, and this job does
# not wait on that one, so it repeats the condition rather than plan and fan
# out shards for a pull request that has already failed.
plan-mutants:
if: >-
github.event_name == 'pull_request'
&& (github.event.pull_request.head.repo.full_name == github.repository
|| github.event.pull_request.head.ref != 'main')
runs-on: ubuntu-24.04
timeout-minutes: 15
env:
# This job builds nothing but the tool, and only when the prebuilt
# binary cannot be had, so the workflow-wide mold flag would make it
# install a linker it has no use for. Cleared here rather than
# installing mold to satisfy a flag.
RUSTFLAGS: ""
outputs:
shards: ${{ steps.plan.outputs.shards }}
width: ${{ steps.plan.outputs.width }}
count: ${{ steps.plan.outputs.count }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
# A workflow, browser or documentation change has no Rust mutants. Detect
# that before installing the toolchain and the mutation tool.
- name: Read the Rust diff
id: diff
env:
BASE_REF: ${{ github.base_ref }}
run: |
git diff "origin/$BASE_REF...HEAD" -- '*.rs' >"$RUNNER_TEMP/pr.diff"
if [ -s "$RUNNER_TEMP/pr.diff" ]; then
echo "changed=true" >>"$GITHUB_OUTPUT"
else
echo "changed=false" >>"$GITHUB_OUTPUT"
fi
- name: Install Rust
if: steps.diff.outputs.changed == 'true'
run: rustup toolchain install
- name: Install cargo-mutants
if: steps.diff.outputs.changed == 'true'
uses: ./.github/actions/install-mutants
- name: Count the mutants this diff is worth
id: plan
run: |
# The default shell is `bash -e` with no pipefail, so the status of a
# pipeline is the last command's. Without this, a cargo-mutants that
# died would leave count at zero from `wc`, the shard job would skip
# on `count != '0'`, and the pull request would go green having
# mutation-tested nothing. The list goes to a file first so "cargo
# said zero" and "cargo failed" cannot look alike.
set -o pipefail
count=0
if [ -s "$RUNNER_TEMP/pr.diff" ]; then
cargo mutants --list --in-diff "$RUNNER_TEMP/pr.diff" >"$RUNNER_TEMP/list.txt"
count=$(wc -l <"$RUNNER_TEMP/list.txt" | tr -d ' ')
fi
width=$(( (count + 14) / 15 ))
[ "$width" -lt 1 ] && width=1
# On the recovery diff, 25 mutants took over 35 minutes on the
# slower runners. Fifteen leaves room for incremental builds and
# failing tests within the same budget. The ceiling permits that
# load up to 960 mutants; shards beyond the account's concurrency
# limit queue rather than fail.
[ "$width" -gt 64 ] && width=64
{
echo "count=$count"
echo "width=$width"
echo "shards=$(seq 0 $((width - 1)) | jq -R . | jq -sc .)"
} >>"$GITHUB_OUTPUT"
echo "$count mutants over $width shard(s)" >>"$GITHUB_STEP_SUMMARY"
# Mutation testing, beside the gates rather than behind them.
#
# `cargo test` proves the tests pass. This proves they can fail. The gap it
# closes is not hypothetical: the CODE_SETTLE gate in livekit.rs shipped with
# its whole predicate revertible to a constant and every Rust test still
# green, because the one test naming the constant had been edited to step
# around it rather than exercise it.
#
# Scoped to the diff, not the crate. The crate is 1840 mutants and hours of
# runner time, and a mutant surviving in code this branch never touched is
# somebody else's gap reported at the worst possible moment. Scoped this way
# the cost tracks the change.
#
# Sharded because that cost is not bounded by anything else here. A 34-file
# refactor produced 98 mutants at roughly 35s each, an hour of one runner,
# which overran the ceiling and told nobody how far it had got. Four shards
# divide the mutants between them, so each finishes in about a quarter of the
# time and each reports its own count and progress. `fail-fast: false` so one
# shard finding a surviving mutant does not cancel the other three and hide
# the rest of the answer.
#
# Pull requests only. A push to main has already merged; the question this
# asks is what a change failed to test, and that is worth answering while the
# change can still be edited.
mutants:
needs: plan-mutants
# No mutants means no runners. A docs-only or JS-only pull request should
# not start a job to discover it has nothing to do.
if: needs.plan-mutants.outputs.count != '0'
runs-on: ubuntu-24.04
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
# Zero-indexed, and it has to stay that way. cargo-mutants slices the
# list as "the first n/k on shard 0", so a one-based matrix silently
# drops every mutant in the first slice and runs an empty last one: on
# a 101-mutant diff that was 75 tested, four green shards, and nothing
# anywhere saying a quarter of it went unexamined. The plan job emits
# zero-based indices; check that per-shard counts sum to the unsharded
# total before trusting any change here.
shard: ${{ fromJson(needs.plan-mutants.outputs.shards) }}
steps:
# Full history: the shard diffs against the base branch, and a shallow
# clone has no merge base to diff from.
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: Reclaim runner disk
uses: ./.github/actions/reclaim-disk
- name: Install native build dependency
uses: ./.github/actions/apt-install
with:
packages: libglib2.0-dev mold sqlite3
- name: Install WebRTC C++ compiler
uses: ./.github/actions/webrtc-clang
- name: Install Rust
run: rustup toolchain install
# Restore, never save. The gates job owns this cache; four shards writing
# a 1 GB entry under one key would be four uploads for the copy that
# happens to finish last. Restoring a prior lockfile's artifacts is what
# keeps each shard's baseline build incremental rather than cold, which is
# the whole reason `--in-place` is worth using.
- name: Restore cargo cache
uses: ./.github/actions/restore-cargo-cache
# Needed even though no browser runs here: a Rust test serves the
# fetched MediaPipe binaries and asserts their content types, and
# cargo-mutants runs the real suite once per mutant.
- name: Restore vendored bytes
uses: ./.github/actions/restore-vendor
- name: Fetch any vendored bytes the cache did not have
run: ./scripts/fetch-vendor.sh
- name: Install cargo-mutants
uses: ./.github/actions/install-mutants
# `--in-place` because cargo-mutants otherwise copies the tree and builds
# it from cold, discarding the target/ the cache above just restored. The
# runner is disposable, so a dirtied tree costs nothing. It is also why
# the shards are separate runners rather than `--jobs`: target/ is tens of
# gigabytes, so a build directory per job does not fit.
#
# The mutant list is printed before the run. cargo-mutants draws progress
# with a terminal bar that renders as nothing in a log, so without this a
# shard looks identical whether it is working or wedged.
- name: Mutation-test the diff
env:
BASE_REF: ${{ github.base_ref }}
SHARD: ${{ matrix.shard }}/${{ needs.plan-mutants.outputs.width }}
run: |
git diff "origin/$BASE_REF...HEAD" -- '*.rs' >"$RUNNER_TEMP/pr.diff"
echo "::group::Mutants in shard $SHARD"
cargo mutants --list --in-diff "$RUNNER_TEMP/pr.diff" \
--shard "$SHARD" \
| tee "$RUNNER_TEMP/shard.txt"
echo "::endgroup::"
echo "shard $SHARD: \
$(wc -l <"$RUNNER_TEMP/shard.txt") mutants" >>"$GITHUB_STEP_SUMMARY"
# Bounded here rather than only by `timeout-minutes`, because a job
# the runner kills is reported as "Cancelled after 45m", which reads
# in the checks list exactly like a human cancelling it or a newer
# push superseding it. This budget is below the job's, so an overrun
# is this step failing with a line saying what overran.
#
# SIGINT and not SIGKILL: cargo-mutants stops on it and writes what
# it had, which the upload below keeps.
# `|| status=$?` and not an `if`, because the shell is `bash -e` and
# the exit status of an `if` whose branch did not run is zero: the
# overrun would be swallowed by the construct meant to report it.
status=0
timeout --signal=INT --kill-after=60s 35m \
cargo mutants --in-place --in-diff "$RUNNER_TEMP/pr.diff" \
--shard "$SHARD" \
|| status=$?
if [ "$status" -eq 124 ]; then
# The shard's size, not what is left: the list is the assignment
# made before the run, and cargo-mutants reports progress only in
# the summary it never got to print. The artifact below is what
# says how far it got.
assigned=$(wc -l <"$RUNNER_TEMP/shard.txt" | tr -d ' ')
echo "::error::shard ${SHARD%/*} ran out of its 35m budget; it was assigned $assigned mutants. Not a surviving mutant: give plan-mutants a higher shard ceiling, or raise both timeouts."
fi
exit "$status"
# An overrun or a surviving mutant both leave the answer in mutants.out,
# and the log carries none of it: cargo-mutants prints one summary line
# at the end and nothing before it. Without this a timed-out shard cannot
# say how far it got.
- name: Keep what the shard managed to test
if: always()
uses: actions/upload-artifact@v7
with:
name: mutants-shard-${{ matrix.shard }}
path: mutants.out
if-no-files-found: ignore
# The release is a binary, not a directory beside a binary: `rust-embed`
# compiles `web/` (including its WASM) into it. Native runners avoid a
# cross-platform linker or packaging toolchain.
#
# Beside `gates` and `audit`, not behind them. A build publishes nothing: its
# archives reach a user only through `release`, which waits on the lanes, so
# starting the legs early cannot ship a commit the gates rejected. Waiting
# put four to seven minutes of build on the end of every push to main, and on
# a red main it spent them anyway. Nothing is lost by running them together:
# they share no cache with the lanes, and the vendored bytes are fetched
# again on the leg that finds none.
build:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
name: ${{ matrix.name }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- name: Linux x86_64
os: ubuntu-24.04
target: x86_64-unknown-linux-gnu
# Keep this major.minor aligned with Cargo.toml's rust-version.
image: rust:1.98.0-bullseye
glibc: '2.31'
glibcxx: '3.4.28'
extension: ''
archive: tar.gz
- name: macOS arm64
os: macos-15
target: aarch64-apple-darwin
extension: ''
archive: zip
- name: Windows x86_64
os: windows-2025
target: x86_64-pc-windows-msvc
extension: .exe
archive: zip
runs-on: ${{ matrix.os }}
env:
# Override the workflow-wide Linux-only mold flag with the
# platform-specific flags required by each release target. Neither
# non-Linux runner can use mold, and MSVC hands -fuse-ld=mold to link.exe,
# which fails with LNK1117. Releases build once per push rather than once
# per mutant, so using the platform's default linker is the right trade.
#
# Even an empty RUSTFLAGS overrides .cargo/config.toml, so Apple targets
# must repeat -ObjC here or the linker omits WebRTC's NSString categories
# and the first room aborts.
RUSTFLAGS: >-
${{ matrix.target == 'x86_64-pc-windows-msvc' && '-C target-feature=+crt-static'
|| contains(matrix.target, '-apple-') && '-Clink-arg=-ObjC'
|| '' }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
# Every target is its runner's host, so the pinned toolchain already has
# its standard library.
- name: Install Rust
run: rustup toolchain install
# The image is part of the key, because on this leg it is the compiler.
# `rust-cache` keys on the runner's rustc, which is no longer the one that
# builds here, and it declines to save when an exact key hit came back: a
# `target/` filled by the old runner build therefore survives under this
# key indefinitely. Let the container's rustc match the runner's once, and
# cargo would call those objects fresh and link glibc 2.39 C objects into
# a Bullseye binary. Naming the image keeps each compiler's artifacts in
# its own entry, and bumping the pin rotates the cache with it.
- uses: Swatinem/rust-cache@v2
with:
key: release-${{ matrix.target }}${{ matrix.image && format('-{0}', matrix.image) || '' }}
# The same entry the gates job saves, on the same key. Without it all
# three platform legs re-download 24 MB that one job already fetched and
# stored on this commit, and the fetch script no-ops on a hit.
- name: Prepare browser assets for embedding
uses: ./.github/actions/prepare-vendor
# The device sign-in task mode uses needs a GitHub OAuth App's client
# ID compiled in. It is public, not a secret; a repository without the
# variable builds a release with no device sign-in, which says so.
- name: Build the self-contained executable
if: runner.os != 'Linux'
env:
CODETRIAL_GITHUB_CLIENT_ID: ${{ vars.CODETRIAL_GITHUB_CLIENT_ID }}
run: cargo build --release --locked -p codetrial --target ${{ matrix.target }}
# GitHub retired the Ubuntu 20.04 runner, but its glibc 2.31 remains the
# useful Linux release baseline. Build inside Bullseye, which carries the
# same glibc, rather than on the runner. This is deliberately not musl:
# the archive stays an ordinary dynamically linked Linux executable.
#
# The image pin in the matrix is the one thing here that has to be
# maintained by hand. Every other leg compiles with the release
# rust-toolchain.toml pins, so this is the only compiler in the workflow
# that can fall behind the one the gates ran, and it runs on push to main
# alone: an API newer than `rust-version` compiles in the pull request and
# breaks the leg that publishes, after the merge. It is the floor, so bump
# it together with `rust-version`, which scripts/test.sh checks.
#
# `--user` rather than the container's root. Root here writes root-owned
# objects into the mounted workspace, `Swatinem/rust-cache` then hits
# EACCES pruning them, and it swallows that error at debug level: the
# cache silently keeps a multi-gigabyte unpruned `target/` and evicts the
# entries the gates depend on.
#
# No `apt-get`, which is what leaves nothing here needing root. The base
# image already carries `libglib2.0-dev`, and reaching for Debian's
# mirrors at build time would buy a scheduled outage: bullseye is past
# its LTS window and moves to archive.debian.org on someone else's
# timetable, which `Acquire::Check-Valid-Until=false` does not answer. A
# base image that ever drops glib fails loudly at pkg-config.
#
# `RUSTUP_TOOLCHAIN` because the image's rustup would otherwise obey
# rust-toolchain.toml in the mounted checkout, download the gates' newer
# release and build with it, and this leg would stop testing the floor.
# The image names its own release in `RUST_VERSION`.
#
# `CARGO_HOME` is the runner's, mounted in. It is what the cache restored
# a step ago, and the container cannot see it otherwise: without this
# every release re-downloads the registry and the prebuilt libwebrtc.
- name: Install Linux WebRTC C++ compiler
if: runner.os == 'Linux'
uses: ./.github/actions/webrtc-clang
- name: Build Linux executable against the compatibility baseline
if: runner.os == 'Linux'
shell: bash
env:
CODETRIAL_GITHUB_CLIENT_ID: ${{ vars.CODETRIAL_GITHUB_CLIENT_ID }}
run: |
docker run --rm \
--user "$(id -u):$(id -g)" \
--volume "$GITHUB_WORKSPACE:/workspace" \
--volume "$HOME/.cargo:/cargo" \
--volume "$RUNNER_TEMP/clang:/clang:ro" \
--workdir /workspace \
--env CARGO_HOME=/cargo \
--env CXX=/clang/bin/clang++ \
--env GITHUB_SHA \
--env CODETRIAL_GITHUB_CLIENT_ID \
${{ matrix.image }} \
sh -c 'RUSTUP_TOOLCHAIN=${RUST_VERSION:?} exec cargo build --release --locked -p codetrial --target "$1"' \
sh ${{ matrix.target }}
# A successful link does not prove the Objective-C categories survived.
# The selector reference remains even when its implementation is absent,
# so match a method name entry rather than any occurrence of the selector.
#
# A relative method list names its selector through a slot in
# __objc_selrefs, and the otool in the runner's Xcode cannot follow that
# slot: it prints "(0x1037c3988 extends past end of file)" where a newer
# one prints the name. Look the slot up in the selref table, which every
# otool decodes, and accept a method entry that points at it.
- name: Verify macOS WebRTC linkage
if: runner.os == 'macOS'
shell: bash
run: |
binary=target/${{ matrix.target }}/release/codetrial
sel=stringForAbslStringView:
# Collect the output so awk and grep cannot give otool SIGPIPE under
# pipefail.
selrefs=$(otool -v -s __DATA __objc_selrefs "$binary")
slot=$(awk -v want="__TEXT:__objc_methname:$sel" \
'$2 == want { sub(/^0+/, "", $1); print $1 }' <<< "$selrefs")
objc=$(otool -oV "$binary")
grep -Eq "^[[:space:]]*name[[:space:]]+0x[0-9a-f]+ (\(0x${slot:-none}[ )]|$sel\$)" <<< "$objc" \
|| { echo 'WebRTC NSString category is missing' >&2; exit 1; }
# Two libraries can set the floor, not one. WebRTC carries Chromium's
# libc++ statically, so today nothing asks for libstdc++ and GLIBCXX_ is
# absent; a dependency that links it again would raise that requirement
# whether or not it touches GLIBC_, and a binary refused for either
# reason is refused the same way: in the loader, before main. So GLIBC_
# must be present and GLIBCXX_ is held to its ceiling only when it is.
# The pattern reads numeric versions only, which is what leaves a name
# like GLIBC_ABI_DT_RELR out: it is a marker, not a version to compare.
- name: Verify Linux release compatibility
if: runner.os == 'Linux'
shell: bash
run: |
binary=target/${{ matrix.target }}/release/codetrial
# Not `readelf | grep -q`. A `shell: bash` step runs under pipefail,
# `grep -q` closes the pipe at its first match, and a producer whose
# output does not fit the pipe buffer then dies of SIGPIPE and takes
# the pipeline's status with it: the check would fail here on a
# binary that passes it. A dynamic section is small enough today that
# it never fires, which is the worst size for a trap to be.
grep -qF 'Shared library: [libc.so.6]' <<< "$(readelf -d "$binary")" \
|| { echo 'Linux release is not dynamically linked to glibc' >&2; exit 1; }
floor() {
required=$(LC_ALL=C readelf -W --version-info "$binary" \
| sed -nE "s/.*Name: $1_([0-9.]+)[[:space:]]+Flags:.*/\1/p" \
| sort -V | tail -n 1)
if [ -z "$required" ]; then
[ "${3:-}" = optional ] && return 0
echo "Linux release has no $1 symbol requirement" >&2
exit 1
fi
newest=$(printf '%s\n%s\n' "$required" "$2" | sort -V | tail -n 1)
[ "$newest" = "$2" ] \
|| { echo "Linux release requires $1_$required, not $1_$2 or older" >&2; exit 1; }
}
floor GLIBC '${{ matrix.glibc }}'
floor GLIBCXX '${{ matrix.glibcxx }}' optional
# The symbol table says the binary asks for nothing newer; this says the
# loader agrees. Every other step that runs it runs it on the runner,
# whose glibc is 2.39, where the failure in issue #30 cannot reproduce.
# `--help` is the whole test: a binary that needs a newer glibc never
# reaches main to print it.
- name: Prove the Linux release starts on the baseline userland
if: runner.os == 'Linux'
shell: bash
run: |
docker run --rm \
--volume "$GITHUB_WORKSPACE:/workspace:ro" \
--workdir /workspace \
debian:bullseye-slim \
./target/${{ matrix.target }}/release/codetrial --help
# The only place the embed is exercised as an embed. `rust-embed` matches
# keys exactly in release and reads the same names off the filesystem in
# debug, so every test binary the suite builds resolves these paths
# through the kernel instead, and normalization bugs are invisible to all
# of them. Run from an empty directory, so a fallback to disk cannot
# answer and make a missing embed look present.
- name: Prove the binary serves without a web tree
if: runner.os == 'Linux'
shell: bash