-
Notifications
You must be signed in to change notification settings - Fork 2
3499 lines (3281 loc) · 187 KB
/
Copy pathci.yml
File metadata and controls
3499 lines (3281 loc) · 187 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
name: CI
on:
push:
branches: [main, master]
pull_request:
# Supersede a run that a newer commit on the same ref has already made
# obsolete. Without this every push queued a *fresh* full run of the ~16 legs
# below and left the previous one in the queue -- so a burst of six merges to
# master queued six complete generations of CI, none of which could be
# cancelled by the next.
#
# That is not only this repository's problem. GitHub's concurrency allowance is
# per ACCOUNT, not per repository, so those generations sit in one queue shared
# with every other repo in the org and starve them: `fastcached` had fifteen
# jobs waiting behind this workflow with none running, and the oldest run here
# had been queued for over five hours.
#
# `docs.yml`, `wasm-demo.yml` and `wasm-ladder.yml` already do this; `ci.yml` is
# by far the largest workflow and was the one without it.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
# Turn morph's own log stream back on for CI. Test binaries default to
# LogLevel::off so a local run is quiet; a CI log is read after the fact, by
# someone diagnosing a failure that has already happened, and the full stream
# is what makes that possible. Set here rather than passed as --log-level
# because every leg runs its suites through `ctest --preset ...` and
# catch_discover_tests registers each test case as its own invocation, so
# there is nowhere to thread an extra argument through. Costs ~2,949 stderr
# lines per suite per leg, which the Valgrind leg pays three times over.
MORPH_TEST_LOG_LEVEL: debug
# vcpkg's x-gha binary caching backend has been removed upstream (confirmed
# live: every Windows CI run logged "warning: The 'x-gha' binary caching
# backend has been removed" and silently cached nothing, rebuilding every
# vcpkg dependency from source on every run). Replaced with the officially
# documented successor: a NuGet-based provider backed by GitHub Packages'
# own NuGet feed, which every repo already has -- no new secret, just the
# windows job's own GITHUB_TOKEN and packages: write (see that job's
# permissions: block and its "Add NuGet source" step, which is what
# actually defines the `github` source this string names).
VCPKG_BINARY_SOURCES: "clear;nuget,github,readwrite"
SCCACHE_DIR: /home/runner/.cache/sccache
# Caps each cache family's on-disk sccache blob. Cache keys are scoped by
# preset name alone (gcc-debug, gcc-release, clang-debug, clang-release,
# clang-asan, clang-tsan, clang-ubsan, clang-coverage), not by job name --
# sccache disambiguates by the actual compiler invocation, so every job that
# builds a given preset (e.g. linux-compilers', linux-qt's, ladder-tests'
# and valgrind's gcc-debug legs) shares one cache family instead of keeping
# its own. That is 8 families total rather than one per job, so this cap
# times 8 stays comfortably inside GitHub's 10 GB per-repo actions-cache
# budget where 16 job-scoped 1 GB blobs did not, and every job's build now
# also warms the cache the next job's build reads from.
#
# The write side still only fires on a push to master (see each job's "Save
# sccache" step) so generations do not keep multiplying underneath this.
# Whether 1 GB is the right cap per family is a question for the
# `sccache --show-stats` output those steps emit -- deliberately left
# unchanged here so the effect of the cache-sharing change can be read on
# its own.
SCCACHE_CACHE_SIZE: "1G"
# Attributes every fastcache-cc invocation to this one job, so the
# "fastcache-cc stats" steps below can report *this* build's hit rate.
# Without it they call `--show-stats` bare, which reads the whole
# invocations log -- a self-hosted runner's lifetime totals across every job
# it has ever run. A run of `Linux / all optional features (clang)` that
# compiled 609 translation units reported "80.0% of 9157 cacheable": roughly
# fifteen jobs' worth, of which this job was 6.6%. The step exists to say
# whether the build was cached and structurally could not, which is the
# failure AGENTS.md puts first -- a hit rate says nothing until you know how
# many compilations it was offered.
#
# A group id rather than `--zero-stats`: zeroing is a process-wide truncation
# of a log this runner's other concurrent jobs are also writing to, so it
# would race them and destroy their numbers as well as the history. The id is
# explicitly not part of the cache key (see `fastcache-cc --help`,
# FASTCACHE_PREFETCH_GROUP), so this partitions the *statistics* and never
# the cache itself -- jobs still read each other's entries.
FASTCACHE_PREFETCH_GROUP: "${{ github.run_id }}-${{ github.job }}"
CLANG_VERSION: "22"
# The Catch2 the `clang-tidy` job analyses against, through the
# clang-tidy-diff.py run inside it. Not a version this
# workflow installs -- `apt-get install -y catch2` takes whatever
# ubuntu-24.04 ships -- but a record of what that has been measured to be,
# which the "Assert the Catch2 this job analyses against" step below reads
# back off the runner and fails on if it has moved.
#
# It is worth recording because it is load-bearing and invisible. The check
# the version decides is `bugprone-chained-comparison`, not
# `readability-function-cognitive-complexity`. Measured with
# clang-tidy 22.1.8, one TEST_CASE of twelve `REQUIRE`s, both releases
# reached the same way (-isystem, i.e. as a system header, which is how this
# runner and a workstation both reach theirs):
#
# bugprone-chained-comparison 3.4.0: 12 3.16.0: 0
# readability-function-cognitive-complexity 3.4.0: 48 3.16.0: 48
#
# Catch2 added `/* NOLINT(bugprone-chained-comparison) */` to
# INTERNAL_CATCH_TEST in 3.15.3; 3.4.0 predates it. That is a pure property
# of the release, it is exactly what the nine examples/*/tests/.clang-tidy
# files subtract the check for, and if the runner's package moves past
# 3.15.3 those nine subtractions become dead suppressions with nothing
# saying so. Half B of scripts/check_catch2_pin.sh under `--strict` is what
# turns that silent move into a failed job.
#
# The cognitive-complexity score is the same 48 under both releases and
# under `-I` and `-isystem` alike, so the version does not decide it. What
# decides whether such a finding is *reported* is whether the diagnostic or
# any of its increment notes lands outside a system header: clang-tidy drops
# a finding all of whose locations are in system headers or system macros. A
# TEST_CASE whose increments come only from Catch2 macro *bodies* is dropped
# here and on a workstation alike; one with an increment spelled in the test
# source -- a hand-written `if`, or a lambda inside a `REQUIRE` argument,
# since macro arguments are spelled in the caller's file -- is reported on
# both. Verified against this job's own red report, run 35581623269:
#
# .../test_bank_gui_qml_behaviour.cpp:115:1: error: function
# 'dummyFunction72' has cognitive complexity of 87 (threshold 25)
# /usr/include/catch2/catch_test_macros.hpp:142:28: note: expanded from
# macro 'TEST_CASE'
# .../test_bank_gui_qml_behaviour.cpp:121:23: note: nesting level
# increased to 2
#
# -- the last line is the lambda in `REQUIRE(pumpUntil([&app] { ... }))`,
# spelled in the test file, and it is why that finding survives the system
# filter. So there is no local/CI asymmetry to pin on this check: a local run
# that exits 0 where this job exits 1 is almost always a `git diff -U0 HEAD`
# that analysed zero files, not a Catch2 difference.
#
# Which artefact this names, checked rather than assumed:
# ubuntu-24.04's package is 3.4.0-1build1 and it ships /usr/lib/cmake/Catch2,
# so `find_package(Catch2 CONFIG QUIET)` at CMakeLists.txt:557 SUCCEEDS on
# this runner and the FetchContent v3.8.1 fallback never runs -- reproduced
# in a clean ubuntu:24.04 container, and corroborated by this job's own log
# (no `_deps/catch2-*`, no dep-cache line for Catch2) and by the report above
# naming /usr/include. The pin therefore guards the installation this job
# really analyses against. Catch2 reaches the translation units through the
# compiler's implicit /usr/include with no include flag at all: over the 751
# entries this job's configure produces, none names catch2.
CATCH2_VERSION: "3.4.0"
# MORPH_BUILD_FORMS_QML needs Qt 6.5+; ubuntu-24.04 apt still ships 6.4.2.
QT_VERSION: "6.8.1"
jobs:
# ── Windows: MSVC + clang-cl ──────────────────────────────────────────
windows:
name: Windows / ${{ matrix.preset }}
runs-on: windows-latest
# packages: write is what lets vcpkg push built binaries to this repo's
# GitHub Packages NuGet feed (see "Add NuGet source" below); nothing else
# in this job needs more than the default contents: read the workflow
# grants at the top level.
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
preset: [cl-debug, cl-release, clangcl-debug, clangcl-release]
steps:
- uses: actions/checkout@v4
- name: Setup MSVC environment
uses: ilammy/msvc-dev-cmd@v1
- name: Setup vcpkg
uses: lukka/run-vcpkg@v11
with:
vcpkgGitCommitId: c3867e714dd3a51c272826eea77267876517ed99
# Defines the `github` source VCPKG_BINARY_SOURCES (top-level env:)
# names. GITHUB_TOKEN is scoped to this repo and already carries
# packages: write from this job's permissions: block above -- no new
# secret needed. `dotnet nuget` rather than `nuget.exe`: it ships with
# the .NET SDK already on every GitHub-hosted Windows runner, so
# nothing extra needs installing.
- name: Add NuGet source (GitHub Packages, for vcpkg binary caching)
shell: pwsh
run: |
dotnet nuget add source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" `
--name github `
--username "${{ github.repository_owner }}" `
--password "${{ secrets.GITHUB_TOKEN }}" `
--store-password-in-clear-text
- name: Configure
run: cmake --preset ${{ matrix.preset }}
- name: Build
run: cmake --build --preset ${{ matrix.preset }}
- name: Test
if: matrix.preset == 'cl-debug' || matrix.preset == 'clangcl-debug'
run: ctest --preset ${{ matrix.preset }}
# Picks a self-hosted lastrada-docker runner for the Linux legs when one is
# registered, online, and idle; falls back to the GitHub-hosted label
# otherwise. The runners are ORGANISATION-level (one fleet serves morph,
# fastcached and Lightweight), so this queries the org endpoint -- the
# repo endpoint returns zero for them and every run would silently go
# GitHub-hosted with no error anywhere.
#
# GITHUB_TOKEN can't call the runners API, so this needs
# RUNNER_STATUS_TOKEN: a PAT with organisation self-hosted-runner read
# access (admin:org, or fine-grained "Self-hosted runners: Read-only" on
# LASTRADA-Software), stored as a repo secret. Forked-repo PRs don't get
# repo secrets at all (the token comes through empty), which this treats
# the same as "no runner online": fall back to GitHub-hosted. A failed
# curl, or a 200 response whose body is missing or the wrong shape for
# `.runners`, are treated the same way. See
# .github/self-hosted-runner/README.md for the fleet itself.
probe-self-hosted:
name: Probe self-hosted runner
runs-on: ubuntu-24.04
outputs:
runs_on: ${{ steps.probe.outputs.runs_on }}
steps:
- name: Check for an idle lastrada-docker runner
id: probe
env:
RUNNER_STATUS_TOKEN: ${{ secrets.RUNNER_STATUS_TOKEN }}
run: |
set -euo pipefail
fallback='["ubuntu-24.04"]'
if [ -z "${RUNNER_STATUS_TOKEN}" ]; then
echo "No RUNNER_STATUS_TOKEN (e.g. a forked-repo PR) -- using GitHub-hosted."
echo "runs_on=${fallback}" >> "$GITHUB_OUTPUT"
exit 0
fi
runners=$(curl -fsSL \
-H "Authorization: Bearer ${RUNNER_STATUS_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/orgs/LASTRADA-Software/actions/runners") || {
echo "Runners API call failed -- using GitHub-hosted."
echo "runs_on=${fallback}" >> "$GITHUB_OUTPUT"
exit 0
}
online=$(echo "${runners}" | jq '[(.runners // [])[] |
select(.status == "online" and .busy == false and
(.labels | map(.name) | contains(["lastrada-docker"])))] | length') || {
echo "Malformed runners response -- using GitHub-hosted."
echo "runs_on=${fallback}" >> "$GITHUB_OUTPUT"
exit 0
}
if ! [[ "${online}" =~ ^[0-9]+$ ]]; then
online=0
fi
if [ "${online}" -gt 0 ]; then
echo "Found an idle lastrada-docker runner -- using self-hosted."
echo 'runs_on=["self-hosted", "Linux", "X64", "lastrada-docker"]' >> "$GITHUB_OUTPUT"
else
echo "No idle lastrada-docker runner -- using GitHub-hosted."
echo "runs_on=${fallback}" >> "$GITHUB_OUTPUT"
fi
# ── Linux: GCC and Clang plain builds ────────────────────────────────
linux-compilers:
name: Linux / ${{ matrix.preset }}
needs: probe-self-hosted
runs-on: ${{ fromJSON(needs.probe-self-hosted.outputs.runs_on) }}
env:
# Only set on the self-hosted path: host.docker.internal does not
# resolve on a GitHub-hosted VM (it is not a Docker container), and an
# address that is merely unreachable rather than refusing the
# connection stalls fastcache-cc's TCP connect for up to ~2m30s per
# matrix leg (CompileCache.cmake's own measured worst case) instead of
# failing fast -- so this must stay empty on that path, not just
# "wrong but harmless".
FASTCACHE_ADDR: ${{ contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted') && 'host.docker.internal:6674' || '' }}
# FASTCACHE_AUTO_INSTALL is a plain CMake option(), not something
# CompileCache.cmake reads from the environment the way it does
# FASTCACHE_ADDR -- an env var of this name is silently ignored, only
# -DFASTCACHE_AUTO_INSTALL=ON on the cmake command line reaches it
# (confirmed live: FASTCACHE_ADDR landed in CMakeCache.txt from the
# environment, FASTCACHE_AUTO_INSTALL stayed OFF). This is that flag,
# precomputed once so every Configure step below can just splice it in.
MORPH_FASTCACHE_AUTO_INSTALL_FLAG: ${{ contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted') && '-DFASTCACHE_AUTO_INSTALL=ON' || '' }}
strategy:
fail-fast: false
matrix:
preset: [gcc-debug, gcc-release, clang-debug, clang-release]
steps:
- uses: actions/checkout@v4
- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
key: apt-compilers-${{ matrix.preset }}-${{ hashFiles('.github/workflows/ci.yml') }}
restore-keys: apt-compilers-${{ matrix.preset }}-
- name: Install GCC 15 and ninja
if: startsWith(matrix.preset, 'gcc-')
run: |
sudo apt-get update -q
sudo apt-get install -y software-properties-common
sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test
sudo apt-get update -q
sudo apt-get install -y gcc-15 g++-15 ninja-build catch2
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-15 15
sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-15 15
- name: Install Clang ${{ env.CLANG_VERSION }} from apt.llvm.org
if: startsWith(matrix.preset, 'clang-')
run: |
sudo apt-get update -q
sudo apt-get install -y ninja-build catch2
# Download, check, then execute -- not
# `wget -qO- https://apt.llvm.org/llvm.sh | sudo bash`.
# `wget -q` writes no error document, so on an HTTP 4xx/5xx it exits
# 8 having written zero bytes. `bash` then reads an empty script,
# does nothing, and exits 0 -- and GitHub runs `run:` under `bash -e`
# without pipefail, so the pipeline's status is bash's, not wget's.
# That step reports success having installed no compiler, and the leg
# goes red several steps later at Configure, naming a missing
# compiler rather than the download that never happened.
#
# Measured against apt.llvm.org itself, on a path that 404s:
#
# $ curl -sSL -o llvm.sh https://apt.llvm.org/does-not-exist.sh
# exit=0 564 bytes of error page
# $ curl -sSL --fail -o llvm.sh https://apt.llvm.org/does-not-exist.sh
# curl: (22) The requested URL returned error: 404
# exit=22
#
# Same three lines as the `Install sccache` steps, so the
# repository has one download idiom rather than two: `--fail` so the
# download reports its own failure, a file so nothing downstream runs
# when it does, and `test -s` for the 200-with-empty-body case that
# `--fail` cannot see. This is not a retry policy and it does not make
# an apt.llvm.org outage less likely -- it makes the step that failed
# be the step the log names.
curl -sSL --fail -o /tmp/llvm.sh https://apt.llvm.org/llvm.sh
test -s /tmp/llvm.sh
sudo bash /tmp/llvm.sh ${{ env.CLANG_VERSION }}
# Keyed by preset alone, not this job's name: gcc-debug/clang-debug's
# object files are the same ones linux-qt, ladder-tests, valgrind (for
# gcc-debug) and clang-tidy (for clang-debug) compile with extra
# -DMORPH_BUILD_* flags layered on top. sccache disambiguates by the
# actual compiler invocation, not by this key, so sharing one cache
# entry per preset across jobs only raises the hit rate -- it cannot
# serve a wrong object for a different flag set.
# Skipped on the self-hosted path: CompileCache.cmake's
# FASTCACHE_AUTO_INSTALL only fetches fastcache-cc when NONE of
# fastcache-cc/sccache/ccache are already on PATH ("only when there is
# nothing else to use" -- see that file's own comment on the guard).
# Installing sccache unconditionally, as this job used to, permanently
# defeated auto-install regardless of FASTCACHE_AUTO_INSTALL=ON:
# sccache was always present, so the auto-install guard's `AND NOT
# SCCACHE` was always false. GitHub-hosted's fallback still gets
# sccache exactly as before.
- name: Restore sccache
if: "!contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted')"
uses: actions/cache/restore@v4
with:
path: /home/runner/.cache/sccache
key: sccache-${{ matrix.preset }}-${{ github.sha }}
restore-keys: sccache-${{ matrix.preset }}-
- name: Install sccache
if: "!contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted')"
run: |
# `--fail`, and a file rather than a pipe into tar. Piped, a host
# that serves an error page instead of a tarball kills the step with
#
# gzip: stdin: not in gzip format
# tar: Child returned status 1
# tar: Error is not recoverable: exiting now
#
# because plain `curl` reports an HTTP 4xx/5xx as a *successful*
# transfer of that page, exit 0, straight down the pipe. The only
# diagnostic anyone sees names the decompressor, and the event that
# actually happened -- the download failed, with a status -- appears
# nowhere in the log. `set -o pipefail` cannot salvage it either:
# GitHub runs `run:` under `bash -e`, without pipefail, so the
# pipeline's status is tar's regardless of what curl did.
#
# `--fail` makes curl exit non-zero and print `curl: (22) The
# requested URL returned error: 503`; writing to a file means tar
# never runs at all when it does. This is not a retry policy and it
# does not make the outage less likely -- it makes the log name the
# thing that happened, which is the half of the problem that needs no
# CI-wide decision. The rest of that class -- a retry policy, or a
# marker that distinguishes "the environment failed" from "the
# change failed" in the check list, and the apt/PPA outages that
# are the other three quarters of the ticket -- is still open.
curl -sSL --fail -o /tmp/sccache.tar.gz \
https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz
tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \
sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
# No -DCMAKE_..._COMPILER_LAUNCHER=sccache here on purpose: leaving the
# launcher unset lets cmake/CompileCache.cmake's own auto-detection run,
# which prefers fastcache-cc (this job's FASTCACHE_ADDR, set above, only
# when running self-hosted) and falls back to the sccache install steps
# above when no daemon answers -- e.g. this exact job on the
# GitHub-hosted fallback runner, which never gets a FASTCACHE_ADDR to
# begin with. Setting the launcher explicitly, as every other Linux job
# still does, would make CompileCache.cmake bail out before it ever
# probes for fastcache-cc (see that file's own comment).
- name: Configure (gcc)
if: startsWith(matrix.preset, 'gcc-')
run: cmake --preset ${{ matrix.preset }} $MORPH_FASTCACHE_AUTO_INSTALL_FLAG
- name: Configure (clang)
if: startsWith(matrix.preset, 'clang-')
run: |
cmake --preset ${{ matrix.preset }} \
-DCMAKE_C_COMPILER=clang-${{ env.CLANG_VERSION }} \
-DCMAKE_CXX_COMPILER=clang++-${{ env.CLANG_VERSION }} \
$MORPH_FASTCACHE_AUTO_INSTALL_FLAG
- name: Build
run: cmake --build --preset ${{ matrix.preset }}
- name: Test
if: endsWith(matrix.preset, '-debug')
run: ctest --preset ${{ matrix.preset }}
# Cumulative hit/miss for this leg. Without it the cache is
# unfalsifiable: a thrashing cache and a working one look identical
# from the outside, and only the build-step duration hints at which
# one you have. `|| true` so a stats failure never fails the job.
#
# Guarded the same way as the Install/Restore sccache steps above:
# sccache is never installed on the self-hosted path (fastcache-cc is
# used there instead), so `sccache --show-stats` would just fail
# "command not found" there.
- name: sccache stats
if: "always() && !contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted')"
run: sccache --show-stats || true
# Cache statistics for this leg. The `sccache stats` step is skipped
# whenever the probe put this job on the self-hosted fleet, because
# sccache is not the launcher there -- fastcache-cc is. Nothing replaced
# it, so those legs ran with no cache reporting at all: exactly the
# "unfalsifiable" state the sccache step's own comment warns about, where
# a thrashing cache and a working one look identical from the outside and
# only the build duration hints at which one you have.
#
# The line that matters is
# unavailable : N (x% of all compiles -- CACHE NOT REACHED)
# which is how a daemon that is configured but never answering shows up.
# Configure still prints "Enabling fastcache-cc at ..." in that case and
# the build simply takes as long as a cold one, so no other signal in the
# job would reveal it.
#
# Mirror of the sccache step's condition, so exactly one of the two runs.
# `|| true` for the same reason it has one: a stats failure must never
# fail the job.
- name: fastcache-cc stats
if: "always() && contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted')"
run: |
FC="$(command -v fastcache-cc || true)"
if [ -z "$FC" ]; then
FC="$(ls -t "$HOME"/.cache/fastcache-cc/*/*/fastcache-cc 2>/dev/null | head -1 || true)"
fi
if [ -z "$FC" ]; then
echo "::warning::fastcache-cc not found on a self-hosted runner -- the build ran uncached."
exit 0
fi
echo "launcher: $FC FASTCACHE_ADDR=${FASTCACHE_ADDR:-<unset>}"
# This job's own numbers, not the runner's lifetime totals. The
# bare form is kept underneath because the whole-runner view is
# what shows eviction pressure across jobs, which a single job's
# slice cannot.
"$FC" --show-stats --prefetch-group "$FASTCACHE_PREFETCH_GROUP" || true
echo "--- whole runner, all jobs (context for the above) ---"
"$FC" --show-stats || true
# Save only on a push to master. Every run used to write a new
# per-commit entry, and with 16 cache-writing legs at 1 GB each one
# generation already exceeds GitHub's 10 GB per-repo budget -- so
# legs evicted each other and entries rarely survived to be reused.
# Restricting writes to master keeps the property that makes the cache
# usable at all (only one branch ever writes, so no branch can delete
# another's entry) while collapsing the generations that actually blow
# the budget. Pull requests still restore, read-only, and get the full
# benefit.
- name: Save sccache
if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') && !contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted')
uses: actions/cache/save@v4
with:
path: /home/runner/.cache/sccache
key: sccache-${{ matrix.preset }}-${{ github.sha }}
# ── Linux: sanitizers + coverage (all clang) ─────────────────────────
linux-sanitizers:
name: Linux / ${{ matrix.preset }}
needs: probe-self-hosted
runs-on: ${{ fromJSON(needs.probe-self-hosted.outputs.runs_on) }}
env:
# See linux-compilers' identical env block for why FASTCACHE_ADDR is
# conditional, and why FASTCACHE_AUTO_INSTALL has to be a -D flag
# (MORPH_FASTCACHE_AUTO_INSTALL_FLAG) rather than an env var of that
# name -- CompileCache.cmake declares it as a plain option(), which
# CMake never reads from the environment.
FASTCACHE_ADDR: ${{ contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted') && 'host.docker.internal:6674' || '' }}
MORPH_FASTCACHE_AUTO_INSTALL_FLAG: ${{ contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted') && '-DFASTCACHE_AUTO_INSTALL=ON' || '' }}
strategy:
fail-fast: false
matrix:
preset: [clang-asan, clang-tsan, clang-ubsan]
steps:
- uses: actions/checkout@v4
- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
key: apt-sanitizers-${{ hashFiles('.github/workflows/ci.yml') }}
restore-keys: apt-sanitizers-
- name: Install Clang ${{ env.CLANG_VERSION }} from apt.llvm.org
run: |
sudo apt-get update -q
sudo apt-get install -y ninja-build catch2 libsqlite3-dev
# --fail, a file, and a non-empty check -- never `wget -qO- | sudo
# bash`: see linux-compilers' identical install step for why the piped
# form reports success having installed nothing.
curl -sSL --fail -o /tmp/llvm.sh https://apt.llvm.org/llvm.sh
test -s /tmp/llvm.sh
sudo bash /tmp/llvm.sh ${{ env.CLANG_VERSION }}
# Keyed by preset alone, matching linux-compilers' cache above: this
# matrix's clang-asan/clang-tsan legs share the same preset name (and
# much of the same object set) as ladder-sanitizers and kanban-tsan
# respectively, which build the identical compiler+preset with extra
# -DMORPH_BUILD_* flags on top. See linux-compilers' identical comment
# for why sharing the key across jobs is safe.
# Skipped on the self-hosted path: see linux-compilers' identical
# Restore/Install sccache steps for why (installing sccache
# unconditionally would permanently defeat fastcache-cc auto-install).
- name: Restore sccache
if: "!contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted')"
uses: actions/cache/restore@v4
with:
path: /home/runner/.cache/sccache
key: sccache-${{ matrix.preset }}-${{ github.sha }}
restore-keys: sccache-${{ matrix.preset }}-
- name: Install sccache
if: "!contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted')"
run: |
# `--fail` and a file rather than a pipe into tar: see
# linux-compilers' identical Install sccache step for why an
# unchecked download reports itself as "not in gzip format".
curl -sSL --fail -o /tmp/sccache.tar.gz \
https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz
tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \
sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
# morph::net and the SQLite offline queue are opt-in, but they are also
# where the memory/threading/UB risk actually lives (raw sockets, an I/O
# thread, a hand-rolled frame reader, a C API). Left off, the sanitizers
# silently skip them. QML, the fuzzers, and the ladder stay out of
# this matrix entirely — they are covered by linux-coverage and
# linux-all-features, and "a GUI stack under TSan is mostly noise".
#
# No -DCMAKE_..._COMPILER_LAUNCHER=sccache: see linux-compilers' Configure
# steps for why leaving it unset is what lets fastcache-cc be selected.
- name: Configure
run: |
cmake --preset ${{ matrix.preset }} \
-DMORPH_BUILD_NET=ON \
-DMORPH_BUILD_OFFLINE_SQLITE=ON \
-DCMAKE_C_COMPILER=clang-${{ env.CLANG_VERSION }} \
-DCMAKE_CXX_COMPILER=clang++-${{ env.CLANG_VERSION }} \
$MORPH_FASTCACHE_AUTO_INSTALL_FLAG
- name: Build
run: cmake --build --preset ${{ matrix.preset }}
# A sanitizer job whose binaries are not instrumented runs the whole suite
# and learns nothing, while reporting success. Asserting this over one
# job's binaries, or for `__asan_` alone, is vacuous on the tsan and
# ubsan legs -- the same "control that measures nothing" it exists to
# prevent. This walks what ctest will actually run and keys the expected
# symbol on the preset, so none of the three legs can pass on a blind
# build.
#
# No `QT_QPA_PLATFORM: offscreen` here, unlike the identical step in
# bank-sanitizers, kanban-tsan and ladder-sanitizers, and the absence is
# deliberate rather than an omission. The sweep's first act is
# `ctest --show-only=json-v1`, which *executes* every DISCOVERY_MODE
# PRE_TEST binary to enumerate its cases; a Qt-linked one aborts headless
# and takes the whole listing with it. This matrix's Configure above sets
# neither MORPH_BUILD_QT nor MORPH_BUILD_LADDER, so it builds no Qt-linked
# suite for that to happen to, and its own Test step does not declare the
# variable either. Adding it for symmetry would be a line protecting
# against nothing, which invites the next reader to look for what it
# protects. Revisit the moment this matrix gains a Qt target.
- name: Every ctest binary is instrumented
run: |
case "${{ matrix.preset }}" in
clang-asan) mode=asan ;;
clang-tsan) mode=tsan ;;
clang-ubsan) mode=ubsan ;;
*) echo "::error::no sanitizer mode for preset ${{ matrix.preset }}"; exit 1 ;;
esac
bash scripts/check_sanitizer_instrumentation.sh build/${{ matrix.preset }} "$mode"
- name: Test
env:
# Known-false-positive suppressions, the same treatment
# cmake/valgrind.supp already gets on the Valgrind job below. The
# entries are libstdc++'s two refcounts for a shared exception --
# exception_ptr's, and the COW string holding the message -- both
# decremented inside an uninstrumented libstdc++.so.6, so TSan
# gets no happens-before edge between the last thread to read a
# shared exception's what() and the thread whose release frees it.
# The file itself carries the measured evidence and
# says why each of the three frames it names still resolves
# against the runner's stripped libstdc++.
#
# Harmless on the clang-asan and clang-ubsan legs of this matrix:
# neither runtime reads TSAN_OPTIONS.
#
# `second_deadlock_stack=1` is colon-separated *into this value*, not
# a second `TSAN_OPTIONS:` key -- a second key silently replaces the
# first and drops the suppressions file, leaving the job green and
# unsuppressed. It makes TSan print, for a lock-order inversion,
# the stacks where the *already-held* mutexes were taken. Measured on
# a two-mutex inversion under clang 22.1.8: 36 lines -> 55, the extra
# 19 being two `Mutex Mn previously acquired by the same thread here:`
# stacks that name the acquiring function and line. Without it TSan
# prints only `Hint: use TSAN_OPTIONS=second_deadlock_stack=1 to get
# more informative warning message` -- advice nobody can take after
# the fact, because the races this job catches are intermittent and
# the run that fires is the only evidence that will ever exist. No
# measurable runtime cost: 2M lock acquisitions under TSan take a
# median 0.275s without and 0.273s with.
TSAN_OPTIONS: suppressions=${{ github.workspace }}/cmake/tsan.supp:second_deadlock_stack=1
run: |
# morph::testkit::OomInjector (tests/oom_injector.cpp) overrides
# the process-wide operator new/delete to force std::bad_alloc on
# demand. ASan and TSan's own runtimes already interpose operator
# new/delete themselves, so the file compiles its overrides *out*
# under either sanitizer rather than linking a second definition
# beside theirs.
#
# The link does *not* fail with "multiple definition of
# `operator new(unsigned long)'", which is the obvious guess. It
# succeeds, and the tests fail at *runtime* instead, because
# OomInjector's constructor throws when its overrides are compiled
# out --
#
# OomInjector: unusable under ASan/TSan (operator new/delete
# overrides are compiled out)
#
# -- which is precisely what a ctest-level exclusion handles, and is
# why the exclusion below works at all. Measured with the filter
# bypassed: six failures on each of the two legs, the five
# `OomInjector` cases plus the one whose name the second alternative
# matches. Deleting the -E filter turns both legs red.
#
# Excluded by test name on exactly these two legs; every other CI leg
# (plain clang/gcc, Windows, ubsan, coverage) runs these tests
# normally -- the guard keys on __SANITIZE_ADDRESS__/
# __SANITIZE_THREAD__, which no other leg defines.
EXCLUDE_ARGS=()
if [ "${{ matrix.preset }}" = "clang-asan" ] || [ "${{ matrix.preset }}" = "clang-tsan" ]; then
EXCLUDE_ARGS+=(-E "OomInjector|morph#108")
fi
ctest --preset ${{ matrix.preset }} "${EXCLUDE_ARGS[@]}"
# Cache statistics for this leg. The `sccache stats` step is skipped
# whenever the probe put this job on the self-hosted fleet, because
# sccache is not the launcher there -- fastcache-cc is. Nothing replaced
# it, so those legs ran with no cache reporting at all: exactly the
# "unfalsifiable" state the sccache step's own comment warns about, where
# a thrashing cache and a working one look identical from the outside and
# only the build duration hints at which one you have.
#
# The line that matters is
# unavailable : N (x% of all compiles -- CACHE NOT REACHED)
# which is how a daemon that is configured but never answering shows up.
# Configure still prints "Enabling fastcache-cc at ..." in that case and
# the build simply takes as long as a cold one, so no other signal in the
# job would reveal it.
#
# Mirror of the sccache step's condition, so exactly one of the two runs.
# `|| true` for the same reason it has one: a stats failure must never
# fail the job.
- name: fastcache-cc stats
if: "always() && contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted')"
run: |
FC="$(command -v fastcache-cc || true)"
if [ -z "$FC" ]; then
FC="$(ls -t "$HOME"/.cache/fastcache-cc/*/*/fastcache-cc 2>/dev/null | head -1 || true)"
fi
if [ -z "$FC" ]; then
echo "::warning::fastcache-cc not found on a self-hosted runner -- the build ran uncached."
exit 0
fi
echo "launcher: $FC FASTCACHE_ADDR=${FASTCACHE_ADDR:-<unset>}"
# This job's own numbers, not the runner's lifetime totals. The
# bare form is kept underneath because the whole-runner view is
# what shows eviction pressure across jobs, which a single job's
# slice cannot.
"$FC" --show-stats --prefetch-group "$FASTCACHE_PREFETCH_GROUP" || true
echo "--- whole runner, all jobs (context for the above) ---"
"$FC" --show-stats || true
# ── Linux: coverage (clang, ladder + Qt) ─────────────────────────────
#
# Split out of linux-sanitizers (which used to carry this as a 4th
# clang-coverage matrix leg) so plain asan/tsan/ubsan can move onto the
# self-hosted probe while this leg -- the one that builds the full
# ladder stack (ODBC/SQLite/yaml-cpp/zip) and Qt, and uploads to
# Codecov -- stays pinned to ubuntu-24.04.
linux-coverage:
name: Linux / clang-coverage
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
key: apt-coverage-${{ hashFiles('.github/workflows/ci.yml') }}
restore-keys: apt-coverage-
- name: Install Clang ${{ env.CLANG_VERSION }} from apt.llvm.org
run: |
sudo apt-get update -q
sudo apt-get install -y ninja-build catch2 libsqlite3-dev
# --fail, a file, and a non-empty check -- never `wget -qO- | sudo
# bash`: see linux-compilers' identical install step for why the piped
# form reports success having installed nothing.
curl -sSL --fail -o /tmp/llvm.sh https://apt.llvm.org/llvm.sh
test -s /tmp/llvm.sh
sudo bash /tmp/llvm.sh ${{ env.CLANG_VERSION }}
# examples/common's hand-written GUI/testkit code is real coverage of
# morph's client stack (Bridge, backends, QtExecutor, completions --
# see examples/TESTING.md's "round-7 T4 reframe"), so it belongs in
# the coverage number the same way the models it will host later do
# (examples/IMPLEMENTATION.md rule 5).
- name: Install ODBC + SQLite driver
run: |
# unixodbc-dev + libsqliteodbc: the application ladder (built by
# this job) fetches the Lightweight ORM, whose CMake runs
# `pkg_check_modules(ODBC REQUIRED odbc)`, and whose ladder fixtures
# open a real `DRIVER=SQLite3` connection at test time.
# Named explicitly rather than relied on from the runner image.
# libyaml-cpp-dev + libzip-dev: Lightweight's own CMakeLists.txt
# does `find_package(yaml-cpp)`/`find_package(libzip)` as system
# CONFIG packages, not through CPM (examples/bank/CMakeLists.txt's
# comment on the identical fetch) — without these, configure fails
# the moment MORPH_BUILD_LADDER=ON pulls Lightweight in.
# Dropped from this step by mistake when it was renamed from
# "Install Qt6 WebSockets" to "Install ODBC + SQLite driver" —
# every other job that builds the ladder on Linux (Application
# ladder, all optional features) already carries this pair.
sudo apt-get install -y libgl1-mesa-dev unixodbc-dev libsqliteodbc libyaml-cpp-dev libzip-dev
# Not the distro's Qt: examples/common/CMakeLists.txt requires 6.5+
# unconditionally (not gated on MORPH_BUILD_FORMS_QML) and Ubuntu
# 24.04 still ships 6.4.2 -- the identical gap the "all optional
# features" and "Application ladder" jobs' own install-qt-action steps
# already document. Named qt6-base-dev/qt6-websockets-dev/qt6-tools-dev
# used to be installed above; replaced wholesale rather than kept
# alongside aqtinstall's Qt, which would leave two Qt6 installs on the
# same runner for find_package() to pick between.
- name: Install Qt ${{ env.QT_VERSION }}
uses: jurplel/install-qt-action@v4.3.1
with:
version: ${{ env.QT_VERSION }}
modules: qtwebsockets
cache: true
- name: Restore sccache
uses: actions/cache/restore@v4
with:
path: /home/runner/.cache/sccache
key: sccache-clang-coverage-${{ github.sha }}
restore-keys: sccache-clang-coverage-
- name: Install sccache
run: |
# `--fail` and a file rather than a pipe into tar: see
# linux-compilers' identical Install sccache step for why an
# unchecked download reports itself as "not in gzip format".
curl -sSL --fail -o /tmp/sccache.tar.gz \
https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz
tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \
sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
- name: Configure
run: |
cmake --preset clang-coverage \
-DMORPH_BUILD_NET=ON \
-DMORPH_BUILD_OFFLINE_SQLITE=ON \
-DMORPH_BUILD_QT=ON \
-DMORPH_BUILD_LADDER=ON \
-DMORPH_LADDER_RUNGS=all \
-DCMAKE_C_COMPILER=clang-${{ env.CLANG_VERSION }} \
-DCMAKE_CXX_COMPILER=clang++-${{ env.CLANG_VERSION }} \
-DCMAKE_C_COMPILER_LAUNCHER=sccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=sccache
# QT_QPA_PLATFORM=offscreen here too, not just on Test below: Catch2's
# catch_discover_tests() runs each Qt-linked test binary once at BUILD
# time to enumerate its cases, which can abort on this headless runner
# without it — see "Linux / all optional features"'s own Build step
# for the identical failure this job's build hit once the ladder
# actually started compiling (no QML here, a narrower Qt surface, but
# ladder_common_tests still links Qt6::WebSockets).
- name: Build
env:
QT_QPA_PLATFORM: offscreen
run: cmake --build --preset clang-coverage
- name: Test
env:
# Required for the ladder tests, which open real Qt widgets/sockets
# on a runner with no display.
QT_QPA_PLATFORM: offscreen
run: |
LLVM_PROFILE_FILE="build/clang-coverage/%p.profraw" ctest --preset clang-coverage
# Deliberately left on the implicit `success()`, unlike the three test
# steps that carry `!cancelled()` (ladder-tests' scenario corpus,
# ladder-sanitizers' Qt transport suites, linux-all-features' fuzz-replay
# verification). This is a decision, not an oversight, and it is written
# down so it is not "fixed" later: this step and the two below it consume
# the Test step's `.profraw` output rather than measuring a surface of
# their own. Forcing them to run after a failed or truncated test pass
# would produce a coverage *number* computed from partial profile data --
# and a number nobody should trust is worse than no number. The rule the
# three others follow is "a red leg reports everything it was able to
# measure"; here, nothing was.
- name: Generate coverage report
run: bash scripts/coverage.sh
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
files: build/clang-coverage/coverage.lcov
# Without this, codecov-action's own auto-discovery finds every
# other *.lcov/coverage.* file under the build tree too (its own
# log says so: "Found 5 coverage files to report") -- including
# coverage.lcov.raw, the *pre-aggregation* file
# aggregate_lcov_branches.py rewrites away from, plus unrelated
# fetched-dependency fixtures
# (_deps/nlohmann_json-src/.../coverage.test). Codecov then merges
# all of them, silently re-introducing every record `coverage.lcov`
# was built to remove. `files:` alone does not disable that scan --
# this does.
disable_search: true
token: ${{ secrets.CODECOV_TOKEN }}
# No `if:` guard, and none may be added that names `matrix`. This job has
# no matrix, so `matrix.preset` evaluates to the empty string and any
# condition testing it is false on every run -- the step is then skipped
# forever, and a skipped step and a successful one are rendered the same
# way, so nothing reports it.
- name: Upload coverage HTML
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: build/clang-coverage/html/
# llvm-cov show emits one page per source file across include/morph,
# examples/common and every rung -- a few thousand small files, which
# upload-artifact pays per-file overhead on. Bounded rather than kept
# forever, since the step runs on every coverage build.
retention-days: 14
# Cumulative hit/miss for this leg. Without it the cache is
# unfalsifiable: a thrashing cache and a working one look identical
# from the outside, and only the build-step duration hints at which
# one you have. `|| true` so a stats failure never fails the job.
- name: sccache stats
if: always()
run: sccache --show-stats || true
# Save only on a push to master. Writing a new per-commit entry from
# every run does not work here: with 16 cache-writing legs at 1 GB each,
# one generation exceeds GitHub's 10 GB per-repo budget, so legs evict
# each other and entries rarely survive to be reused. Restricting writes
# to master keeps the property that makes the cache usable at all (only
# one branch ever writes, so no branch can delete another's entry) while
# collapsing the generations that blow the budget. Pull requests still
# restore, read-only, and get the full benefit.
- name: Save sccache
if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')
uses: actions/cache/save@v4
with:
path: /home/runner/.cache/sccache
key: sccache-clang-coverage-${{ github.sha }}
# ── Linux: kanban's concurrent-move stress test under ThreadSanitizer ──
# test_kanban_stress.cpp's [tsan]-tagged TEST_CASE drives BoardModel through
# a bare morph::bridge::Bridge/morph::backend::LocalBackend directly, on a
# real morph::exec::ThreadPoolExecutor{4}, with zero Qt frames anywhere in
# its call graph (see the test file's own header comment) -- so the "a GUI
# stack under TSan is mostly noise" rationale that keeps the ladder out of
# linux-sanitizers does not apply to this one test. This job builds only
# what that test needs -- MORPH_BUILD_LADDER=ON, MORPH_LADDER_RUNGS=kanban,
# no Qt GUI modules beyond the WebSockets backend the ladder testkit itself
# requires -- to keep it a minimal, fast, TSan-clean addition rather than
# pulling every rung's Qt Quick/QML code into the sanitizer matrix.
#
# The test must not reach a QtExecutor, and the constraint is load-bearing
# rather than stylistic: driving the same scenario through
# BackendRig{Mode::Local, ...} constructs a real morph::qt::QtExecutor for
# client-facing callback delivery, and that produces 165 ThreadSanitizer
# warnings bottoming out in genuine Qt-internal frames -- undetectable as
# real bugs or false positives from outside a TSan-instrumented Qt build.
# Never constructing one sidesteps the ambiguity rather than resolving it.
kanban-tsan:
name: Kanban / ThreadSanitizer
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
key: apt-kanban-tsan-${{ hashFiles('.github/workflows/ci.yml') }}
restore-keys: apt-kanban-tsan-
- name: Install Clang ${{ env.CLANG_VERSION }} from apt.llvm.org
run: |
sudo apt-get update -q
# unixodbc-dev + libsqliteodbc: the ladder (built by this job)
# fetches the Lightweight ORM, whose CMake runs
# `pkg_check_modules(ODBC REQUIRED odbc)`, and whose DbFixture
# opens a real `DRIVER=SQLite3` connection at test time (identical
# rationale to linux-sanitizers' coverage-leg step and ladder-tests'
# own install step). Named explicitly rather than relied on from the
# runner image.
# libyaml-cpp-dev + libzip-dev: Lightweight's own CMakeLists.txt does
# `find_package(yaml-cpp)`/`find_package(libzip)` as system CONFIG
# packages, not through CPM (examples/bank/CMakeLists.txt's comment
# on the identical fetch) — without these, configure fails the
# moment MORPH_BUILD_LADDER=ON pulls Lightweight in.
# libgl1-mesa-dev: every other job that configures MORPH_BUILD_QT=ON
# together with MORPH_BUILD_LADDER=ON installs this (linux-sanitizers'
# coverage leg, ladder-tests, linux-all-features) for Qt's GL platform
# integration; carried here for the same reason even though this leg's
# test run itself stays off-GUI.
sudo apt-get install -y ninja-build catch2 libsqlite3-dev \
unixodbc-dev libsqliteodbc libyaml-cpp-dev libzip-dev libgl1-mesa-dev
# --fail, a file, and a non-empty check -- never `wget -qO- | sudo
# bash`: see linux-compilers' identical install step for why the piped
# form reports success having installed nothing.
curl -sSL --fail -o /tmp/llvm.sh https://apt.llvm.org/llvm.sh
test -s /tmp/llvm.sh
sudo bash /tmp/llvm.sh ${{ env.CLANG_VERSION }}
# Not the distro's Qt: examples/common/CMakeLists.txt requires 6.5+
# unconditionally and Ubuntu 24.04 still ships 6.4.2 — the same gap
# every other job that builds the ladder on Linux already documents.
- name: Install Qt ${{ env.QT_VERSION }}
uses: jurplel/install-qt-action@v4.3.1
with:
version: ${{ env.QT_VERSION }}
modules: qtwebsockets
cache: true
# Keyed by preset alone (clang-tsan), matching linux-sanitizers' cache:
# this job's clang-tsan+Qt+kanban build shares the same compiler and
# much of the same core-library object set as that job's clang-tsan
# leg, which builds plain clang-tsan+net+offline_sqlite. See
# linux-compilers' identical comment for why sharing the key is safe.
- name: Restore sccache
uses: actions/cache/restore@v4
with:
path: /home/runner/.cache/sccache
key: sccache-clang-tsan-${{ github.sha }}
restore-keys: sccache-clang-tsan-
- name: Install sccache
run: |
# `--fail` and a file rather than a pipe into tar: see
# linux-compilers' identical Install sccache step for why an
# unchecked download reports itself as "not in gzip format".
curl -sSL --fail -o /tmp/sccache.tar.gz \
https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz
tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \
sccache-v0.9.1-x86_64-unknown-linux-musl/sccache