-
-
Notifications
You must be signed in to change notification settings - Fork 118
1102 lines (1031 loc) · 61.1 KB
/
Copy pathci.yml
File metadata and controls
1102 lines (1031 loc) · 61.1 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
# ── THE TEST GATE IN THIS FILE IS FROZEN ─────────────────────────────────────
# This workflow does not run the test suite. The Linux `build` job runs NO
# tests; the only `ctest -R` steps are the ones listed in
# .github/ci-test-gate.txt, kept on the macOS and Windows jobs because the
# claim each pins is about THAT platform's toolchain (Apple Metal, MSVC
# portability, #4559's Windows-only separator failure) — not because Linux
# cannot run them; most of them also run on the weekly Linux lane. That list
# does not grow. The full suite runs unfiltered in two places, and a test that
# the default configure builds joins BOTH the moment it is declared: on every
# push to main (full-suite.yml, minutes after a merge — that is the one that
# will tell you) and again weekly under the sanitizers (sanitizers.yml). So a
# new test needs no step here — do not add one, however invisible the defect
# it pins. tools/check_ci_test_gate.py (Static checks) fails any -R pattern
# that resolves to a name the frozen list does not carry. Removing a
# step is welcome: run `python tools/check_ci_test_gate.py --update` and
# commit; the script only ever shrinks the list.
on:
pull_request:
branches: [main]
# Post-merge guard. We dropped the "Require branches up to date before
# merging" branch-protection bit because it forced a CI rerun on every
# PR whenever main moved (~15–25 min × N stale PRs per batch day).
# The trade-off is: CI now runs on the merged commit itself, so any
# semantic conflict that slipped past three-way merge surfaces on main
# within ~10 min and triggers normal failure notifications.
push:
branches: [main]
# Auto-cancel superseded runs when a newer commit lands on the same PR, so a
# rapid push stream doesn't stack full runs. PR runs share a stable per-PR
# group (the older run is cancelled); main runs append the unique run_id so
# each is its own group — main stays fully parallel and never queues or
# cancels, leaving the post-merge push:main guard exactly as it was.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}${{ github.ref == 'refs/heads/main' && format('-{0}', github.run_id) || '' }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
# Least-privilege GITHUB_TOKEN. CI only needs to read repo contents to
# build and test — no writes, no token-bearing API calls. Any future job
# that needs more (e.g. publishing a comment) must opt in at the job level.
permissions:
contents: read
# Qt inputs shared by check-windows and check-macos. Declared once so the two
# per-PR legs cannot drift apart: both aqt invocations and both cache keys
# read these. 6.8.3 matches what community builders run and what
# windows-installer.yml / macos-dmg.yml ship, so the per-PR build and the
# release build agree by construction. aqt is pinned because it is the tool
# that decides what Qt lands here — an unreviewed aqt upgrade is an
# unreviewed change to the Qt CI builds against (same argument as
# .github/docker/Dockerfile).
env:
QT_VERSION: '6.8.3'
AQTINSTALL_VERSION: '3.3.0'
# py7zr is aqt's 7z extractor and is pinned separately, to the same series
# jurplel/install-qt-action pins (py7zrversion default ==1.0.*). Left to
# pip, aqtinstall==3.3.0 resolves py7zr>=0.22.0 to the newest 1.1.x, whose
# symlink sanitising throws `Bad7zFile: Specified path is bad: lib/cmake/…`
# part-way through the Windows extraction, at random (aqtinstall #995 —
# the reporter measured ~50%). It passed on two PR runs of #5552 and then
# failed the first main run, which is exactly the shape of that bug.
# Not in the Qt cache key: the extractor does not change what is laid down.
PY7ZR_VERSION: '1.0.0'
jobs:
build:
runs-on: ubuntu-latest
container: ghcr.io/aethersdr/aethersdr-ci:latest
# Explicit CCACHE_DIR: the job runs as root inside a container, so `~`
# resolves to /root and an actions/cache `path:` of `~/.cache/ccache` would
# depend on the image's HOME. A fixed absolute path is unambiguous on both
# the cache and the compiler side.
env:
CCACHE_DIR: /ccache
# 1500M, revised twice. Read both steps before touching it again.
#
# 1000M -> 2000M (#5008, 2026-08-15). At 1000M this cache was FULL and
# evicting internally — the last green main run before that change:
#
# Cache size (GB): 1.0 / 1.0 (95.41%) Hits 2082/2210 (94.21%)
#
# A cold build settles at ~0.7 GB, but this entry does not hold one
# build: it accumulates objects across ~9.2 main merges a day. At 1.0 GB
# it only ever represented a narrow window of recent main, and a PR
# branched from a day-old main took misses on objects that had fallen
# out of ccache rather than out of the GitHub cache. 2000M fixed that:
# main runs 2026-09-01..05 report Hits 99.2–99.7%, PR runs 91–99%.
#
# 2000M -> 1500M (#5548 item 5, 2026-09-10). The #5008 budget assumed
# entries compress to ~55% of the ccache directory. They do not: ccache
# already compresses its objects, so the entry is ~95% of the directory
# — a full 2000M cache saved as a ~1.88 GiB entry (measured on every
# main save 2026-09-01..05). With the other caches also at their caps
# (sccache-windows 1.02 GiB, canary 0.94, macOS 0.44) and cache-cleanup
# keeping two of each, main alone held ~10.4 GiB against the 10 GiB
# allowance. GitHub evicted by LRU, this entry went first because it was
# the largest, and every main run until the next green save was a cold
# build: 27–37 min instead of 3–6, ten times between 2026-09-06 and
# 2026-09-10. cache-cleanup.yml now keeps one entry per prefix, which
# alone brings main to ~6.1 GiB; 1500M trims the largest entry further
# without giving back the whole 2000M window. Judge it by the "ccache
# stats" step: if main's hit rate settles below the ~99% seen at 2000M,
# the window is too narrow again and the budget below has room to go
# back up. If "Cache size" sits at 100% with a large `Cleanups` count
# AND the hit rate holds, leave it — a full cache that still hits is
# working as designed.
#
# Raised for THIS job only. The other two are not constrained and would
# pay transfer time for nothing — do not raise them on symmetry grounds:
# check-macos 0.3 / 0.5 GB (57.63%)
# check-windows 628 MiB / 1 GiB (61%)
#
# Budget at 1500M, against the repo's 10 GiB cache allowance, with
# cache-cleanup.yml's KEEP=1. Entry sizes are MEASURED on refs/heads/main
# from the "Save" steps, except Linux which is extrapolated from the
# ~95% ratio at the new cap:
# Linux ~1.4 GiB entry x 1 retained = 1.4 GiB
# Windows ~1.02 GiB x 1 = 1.0 GiB
# macOS ~0.44 GiB x 1 = 0.4 GiB
# canary ~0.94 GiB x 1 = 0.9 GiB
# deps (Qt 1.3, install-qt 0.4, DeepFilterNet, FFTW, qtkeychain)
# ~ 1.8 GiB
# --------
# ~ 5.5 GiB
# That is the post-prune FLOOR, not what LRU evaluates. prune-main runs
# on workflow_run AFTER a main save, so between the save and the prune
# every prefix briefly holds two entries — a transient peak of about
# (1.4+1.02+0.44+0.94)x2 + 1.8 = ~9.4 GiB, and main runs are not
# serialised (cancel-in-progress is off for main), so those windows can
# overlap. Budget against the peak, not the floor.
# The canary line is system-libs-canary.yml's cache; it was missed by an
# earlier revision of this table and by cache-cleanup.yml's PREFIXES
# alike, so if you add a compiler cache in ANY workflow, add it to both.
# Dependency caches were also written on PR refs until #5548 item 1
# (~1.7 GiB per open PR that missed — ~12 GiB with the four open PRs
# measured on 2026-09-10, still over the line even after the prune
# change). Since item 1 they restore on PR and save on main only, so
# the figure above is the whole steady state; anything on refs/pull or
# refs/tags is transient and cache-cleanup.yml's sweep reclaims it.
# See "Compiler-cache strategy" at the bottom of this file for why only
# main writes an entry, and cache-cleanup.yml for what prunes the rest.
CCACHE_MAXSIZE: 1500M
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
# ccache is in .github/docker/Dockerfile as of #4655, but this job pins
# the image by :latest tag, so it runs against whatever was last
# published — including builds from before that layer existed. Install
# on demand when the image predates it; ~3 s and a no-op afterwards.
- name: Ensure ccache is present
run: command -v ccache || (apt-get update && apt-get install -y ccache)
# Keep the PR build honest while the rolling CI image catches up with a
# Dockerfile dependency change. This is a no-op once the image contains
# librtlsdr-dev, and ensures the RTL-SDR backend compiles on this head.
- name: Ensure RTL-SDR build dependency is present
run: dpkg -s librtlsdr-dev >/dev/null 2>&1 || (apt-get update && apt-get install -y librtlsdr-dev)
# RESTORE on every run, SAVE only on main — see "Compiler-cache strategy"
# at the bottom of this file for the measurements behind the split.
- name: Restore ccache objects
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: /ccache
# There is deliberately no exact-match key here: the prefix fallback
# IS the mechanism, and restore-keys picks the most recently created
# matching entry, which under save-on-main is the newest main build.
key: ccache-linux-build-
restore-keys: |
ccache-linux-build-
# DEPENDENCY caches: restore on every run, save only on main — the same
# split the compiler caches got in #5008, applied here in #5548. These
# keys are content hashes, so unlike the compiler caches they need no
# run_id. The Save steps sit at the END of each job, gated on the ref,
# on `success()`, and on the MISS (cache-hit != 'true') — see the Save
# DeepFilterNet3 step at the bottom of this job for why all three.
#
# Why it matters: a plain `actions/cache` step saves on a miss from ANY
# ref, and a PR's entry lands on refs/pull/N/merge where nothing else can
# read it. In theory a PR only misses when main's copy is gone; in
# practice (2026-09-10) the repo sat at its 10 GiB allowance, LRU evicted
# main's Qt entry between runs, and four open PRs each held their own
# 1.3 GiB copy — which kept the repo at the allowance, which evicted
# main's compiler caches, which is a 30-minute cold build. Saving from
# main only breaks that loop; cache-cleanup.yml sweeps what remains.
#
# THE COST, accepted deliberately: a PR that changes one of these keys —
# bumps QT_VERSION, edits setup-deepfilter.sh / setup-fftw.ps1 /
# setup-qtkeychain.sh — misses on EVERY push until it merges, because
# the new entry only appears once main has built it. For the macOS Qt
# tree that is the ~6 min -> ~18 min this file already records below.
# Rare, self-limiting, and the right trade against a permanent
# 1.7 GiB × open-PRs tax; it is written here so the next Qt bump is not
# a surprise.
#
# `cache-hit` from actions/cache/restore is 'true' only on an exact key
# match. None of these steps use restore-keys, so the setup-step gating
# below is unchanged from the bidirectional form.
- name: Restore DeepFilterNet3
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
id: cache-deepfilter
with:
path: third_party/deepfilter
key: deepfilter-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('scripts/setup/setup-deepfilter.sh') }}
- name: Setup DeepFilterNet3 (DFNR)
if: steps.cache-deepfilter.outputs.cache-hit != 'true'
run: bash scripts/setup/setup-deepfilter.sh
- name: Zero ccache stats
run: ccache --zero-stats
- name: Configure
run: |
# Redirect, not `| tee`: this job runs in the CI container, which
# ships no bash, so GitHub falls back to `sh -e {0}` — dash, where
# `set -o pipefail` is not a valid option and fails the step outright
# (exit 2). Without pipefail a pipeline's status is tee's, which would
# mask a cmake failure, so the safe form here is no pipeline at all:
# cmake's own exit status is the step's, and the log is echoed after
# so a failed configure stays readable in the step output. The other
# two jobs run on real runners and keep the `tee` form.
#
# AETHER_GPU_SPECTRUM=ON is stated rather than left to the CMake
# default so the assertion below has a request to check against.
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DENABLE_NVIDIA_AFX=ON \
-DAETHER_GPU_SPECTRUM=ON \
-DCMAKE_C_COMPILER_LAUNCHER=ccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache > configure.log 2>&1 || {
cat configure.log
exit 1
}
cat configure.log
# AETHER_GPU_SPECTRUM=ON is a request, not a guarantee: CMake flips it
# back OFF without failing when Qt6GuiPrivate can't be located, so a build
# can ship the CPU QPainter fallback with its workflow green — the failure
# #4000 shipped on aarch64.
#
# appimage.yml, macos-dmg.yml and windows-installer.yml all assert this,
# but every one of them is tag-triggered. Until this step existed nothing
# checked it on a pull request, so a change that silently flipped the
# feature off could sit on main until someone cut a release. Here it costs
# seconds and fails the PR that caused it. The release assertions stay:
# they cover the Qt each artifact actually ships, which is not the Qt any
# of these jobs use.
#
# Assert on the configure output, not CMakeCache.txt: CMakeLists.txt
# disables the feature with a plain set(), which shadows the cache entry
# without rewriting it, so the cache still reads ON when it is off.
- name: Assert GPU spectrum rendering actually enabled
run: |
grep -q "GPU spectrum rendering enabled" configure.log || {
echo "ERROR: -DAETHER_GPU_SPECTRUM=ON but CMake disabled it. Relevant output:"
grep -iE "GPU spectrum|Qt6GuiPrivate|private headers" configure.log || true
exit 1
}
grep "GPU spectrum rendering enabled" configure.log
- name: Build
run: cmake --build build -j$(nproc)
# Mirrors check-windows' "sccache stats" step, and exists for the same
# reason: #1963 disabled the Windows compiler cache for three months
# without anyone noticing, because nothing made the hit rate visible.
# A "Hits 0" line here means the launcher was dropped — read it before
# believing any wall-clock claim about this job. Runs last so it covers
# the main build.
- name: ccache stats
if: always()
run: ccache --show-stats
# Dependency caches are saved LAST, after the build and tests, and only
# from a green main run — the same rule as the compiler-cache save
# below, and the one the bidirectional actions/cache step these replaced
# already gave for free (it saved from a post step with
# `post-if: success()`). A save placed right after the setup script
# would persist a tree the build never validated, under a content-hash
# key that no later run can overwrite and no sweeper matches on main;
# the only recovery would be a manual `gh cache delete`. The
# cache-hit != 'true' half is still needed: a save against a key that
# already exists only warns, and that warning on every main run would
# hide a real one. See the Restore DeepFilterNet3 step for the
# restore-on-PR / save-on-main reasoning.
- name: Save DeepFilterNet3
if: github.ref == 'refs/heads/main' && success() && steps.cache-deepfilter.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: third_party/deepfilter
key: ${{ steps.cache-deepfilter.outputs.cache-primary-key }}
# Save LAST, and only from a green main build.
#
# `success()` is not about correctness — ccache keys each object on a hash
# of the preprocessed source plus flags, so objects from a failed build are
# still valid, just fewer. It is about not letting a build that died early
# write a SPARSE entry that restore-keys then prefers over the fuller one
# before it, because restore-keys resolves to the most recent match. Every
# PR would restore that thinner cache until the next green main run.
- name: Save ccache objects
if: github.ref == 'refs/heads/main' && success()
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: /ccache
# run_id keeps the key unique: actions/cache entries are immutable, so
# a stable key would make every save after the first a silent no-op
# and freeze the cache at its first-ever contents.
key: ccache-linux-build-${{ github.run_id }}
# Cross-platform build check — runs on every PR (no path gating).
#
# History: previously gated by a `check-paths` job on a hand-maintained
# allow-list of files known to carry `#ifdef Q_OS_WIN` / `Q_OS_MAC`
# branches (CMakeLists, third_party/, workflows, MainWindow,
# AudioEngine). The allow-list was a chronic leaker — every time a
# new platform-guarded path got added to a different file, the gap
# surfaced as a release-time regression: #796 (origin), #2671
# (MainWindow added after a miss), #3052 (AudioEngine added after a
# miss), #3210 (re-added after relapse), the v26.5.3
# ClientPhaseRotator.cpp M_PI MSVC break, and the CwSidetonePortAudioSink
# WASAPI fix in #3241 (Windows-only logic never compiled by CI). An
# always-on rule trades ~15-20 min of runner time per PR for a category
# of regression we kept paying for.
#
# Caching strategy:
# * Qt install — aqtinstall into an explicitly cached tree, the
# same shape as check-macos (was install-qt-action's
# cache: true until #5548 — see the Qt step)
# * FFTW3 third_party — actions/cache restore/save, keyed on the setup
# script + version
# * MSVC object files — sccache over a LOCAL dir, persisted by actions/cache
# All of the above restore on every run and save only from main; the
# reasoning is on the Linux job's DeepFilterNet3 step.
#
# sccache used to run on the GitHub Actions cache backend
# (SCCACHE_GHA_ENABLED), which stores each object as its own cache entry
# retrieved by EXACT key. Measured on PR #4683: 716 compile requests, 0 hits,
# 0.00% hit rate, with zero read/write errors — the launcher was working and
# every single object missed. Dumping all 3.4k repo cache entries and
# intersecting the key sets showed why: PR-to-PR the keys are stable
# (712 of 717 shared between two PRs), but main's keys overlap a PR's by 0-1.
# A PR may only read its base branch's cache, so the one cache a PR *can*
# read is the one whose keys never match, and each PR then writes ~717
# private entries nobody else can use. That churn alone had the repo at
# 9.18 GB of the 10 GB budget across 3471 entries, LRU-evicting the shared
# entries and reinforcing the miss.
#
# Read that alongside "Compiler-cache strategy" at the bottom of this file:
# the same ref-scoping fact ("a PR may only read its base branch's cache")
# later turned out to be wasting most of the budget through the DIRECTORY
# caches too, not just the per-object backend this paragraph retired. Same
# root cause, two symptoms, fixed years apart — which is why the note below
# spells out the rule rather than just the fix.
#
# The exact-key requirement is what makes that fatal rather than merely
# lossy: any key drift is a total miss with no fallback. The Linux build
# avoids it by caching a ccache DIRECTORY with prefix `restore-keys:` — it
# restores the newest available cache wholesale and lets ccache hash
# locally, and measures 96.37% (1989/2064) on the same PR. Windows now uses
# that identical shape.
#
# `Cache hits 0` in the "sccache stats" step below still means the cache is
# broken — but check the hit rate before blaming the launcher, since a
# working launcher with a cold backend looks the same at a glance and that
# is exactly what was mis-diagnosed here before.
check-windows:
runs-on: windows-latest
env:
# Local disk cache, not the GHA backend — see the note above. D: is the
# fast runner volume and the one the workspace already lives on.
SCCACHE_DIR: D:\sccache
# Left at 1G. When the Linux ccache cap was first raised (#5008) this one
# measured 628 MiB of 1 GiB on the same run; it has since filled to its
# cap and saves as a ~1.02 GiB entry on every main run (2026-09-01..).
# That is budgeted in the Linux CCACHE_MAXSIZE comment above; before
# raising it, check the "sccache stats" hit rate, and re-do that budget.
# Do not move the caps together on symmetry grounds.
SCCACHE_CACHE_SIZE: 1G
# QT_VERSION / AQTINSTALL_VERSION are workflow-level env (top of file).
# 6.8.3 exposes Qt 6.8.x MOC volume so we catch issues like #1910 (MSVC
# COFF section limit, fixed by /bigobj) before they reach users. QT_ARCH
# is spelled exactly as windows-installer.yml spells it: Qt 6.8 LTS
# dropped the MSVC 2019 builds.
QT_ARCH: 'win64_msvc2022_64'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
# Qt via aqtinstall into an explicitly cached tree — the shape check-macos
# already uses, ported here in #5548. Until then this step was
# jurplel/install-qt-action with `cache: true`, which is aqt plus a
# bidirectional cache with no restore-only mode: every PR that missed
# wrote a 396 MiB entry onto its own refs/pull/N/merge, where nothing
# else could read it. Keyed on everything that changes the tree — Qt
# version, arch, module set, and the aqt version that resolved them.
#
# runner.workspace (D:\a\AetherSDR\Qt), NOT github.workspace as
# check-macos uses: that is where install-qt-action put it, and the path
# is in every Qt-including compile's -I flags, which sccache hashes.
# The first cut of this port moved the tree one directory down and the
# sccache hit rate on an otherwise-identical PR went from 99.26% to
# 19.09% (657 of 812 objects missed, Build 26 min instead of 6). Keeping
# the path keeps main's ~1 GiB sccache entry valid across the port.
- name: Restore Qt
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
id: cache-qt-win
with:
path: ${{ runner.workspace }}\Qt
key: qt-${{ env.QT_VERSION }}-windows-${{ env.QT_ARCH }}-aqt${{ env.AQTINSTALL_VERSION }}-mm.ws.sp.st
- name: Install Qt LTS via aqtinstall
if: steps.cache-qt-win.outputs.cache-hit != 'true'
shell: pwsh
run: |
# aqt goes in a venv and is pinned, for the reason check-macos and
# .github/docker/Dockerfile give: it is the tool that decides what
# Qt lands here, so an unreviewed aqt upgrade is an unreviewed
# change to the Qt this job builds against.
python -m venv "$env:RUNNER_TEMP\aqtvenv"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& "$env:RUNNER_TEMP\aqtvenv\Scripts\pip.exe" install -q "aqtinstall==$env:AQTINSTALL_VERSION" "py7zr==$env:PY7ZR_VERSION"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& "$env:RUNNER_TEMP\aqtvenv\Scripts\aqt.exe" install-qt windows desktop $env:QT_VERSION $env:QT_ARCH `
-m qtmultimedia qtwebsockets qtserialport qtshadertools `
--outputdir "$env:RUNNER_WORKSPACE\Qt"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# Separate from the install so it also runs on a cache hit. Exports the
# same variables install-qt-action did (QT_ROOT_DIR, QT_PLUGIN_PATH,
# QML2_IMPORT_PATH, bin on PATH) plus Qt6_DIR and CMAKE_PREFIX_PATH so
# find_package(Qt6) resolves without a -D on the configure line. PATH
# goes through GITHUB_PATH so it prepends; the test steps below run Qt
# executables and need the DLLs resolvable.
- name: Export Qt environment
shell: pwsh
run: |
# aqt lays the tree down under <version>/<arch minus the win64_ host
# prefix>: win64_msvc2022_64 -> msvc2022_64.
$qtRoot = "$env:RUNNER_WORKSPACE\Qt\$env:QT_VERSION\" + ($env:QT_ARCH -replace '^win64_', '')
if (-not (Test-Path "$qtRoot\bin\qmake.exe")) {
# A bad entry on main is only clearable by hand: the key is a
# content hash nothing re-saves, and no sweeper matches qt-* there.
Write-Host "ERROR: no qmake at $qtRoot — aqt layout changed, or a stale cache entry"
Write-Host " (clear a stale entry: gh cache delete --key <the Restore Qt key> --ref refs/heads/main)"
Get-ChildItem "$env:RUNNER_WORKSPACE\Qt\$env:QT_VERSION" -ErrorAction SilentlyContinue
exit 1
}
@(
"QT_ROOT_DIR=$qtRoot",
"Qt6_DIR=$qtRoot\lib\cmake\Qt6",
"CMAKE_PREFIX_PATH=$qtRoot",
"QT_PLUGIN_PATH=$qtRoot\plugins",
"QML2_IMPORT_PATH=$qtRoot\qml"
) | Out-File -FilePath $env:GITHUB_ENV -Append
"$qtRoot\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
& "$qtRoot\bin\qmake.exe" -query QT_VERSION
- name: Restore FFTW3
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
id: cache-fftw
with:
path: third_party/fftw3
key: ${{ runner.os }}-fftw3-3.3.5-${{ hashFiles('scripts/setup/setup-fftw.ps1') }}
# Gated on the miss, as windows-installer.yml's copy of this step already
# is; the script's output is entirely inside the cached directory.
- name: Install FFTW3
if: steps.cache-fftw.outputs.cache-hit != 'true'
shell: pwsh
run: .\scripts\setup\setup-fftw.ps1
- name: Restore DeepFilterNet3
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
id: cache-deepfilter
with:
path: third_party/deepfilter
key: deepfilter-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('scripts/setup/setup-deepfilter.ps1') }}
- name: Setup DeepFilterNet3 (DFNR)
if: steps.cache-deepfilter.outputs.cache-hit != 'true'
shell: pwsh
run: .\scripts\setup\setup-deepfilter.ps1
# Restored BEFORE sccache first runs, so the server sees a populated
# SCCACHE_DIR when it starts. The prefix fallback is the entire point,
# since it survives the key drift that made the exact-key GHA backend
# miss 100%. Same save-on-main split as the Linux ccache step.
- name: Restore sccache objects
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: D:\sccache
key: sccache-windows-
restore-keys: |
sccache-windows-
- name: Setup sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba
# CMAKE_CXX_COMPILER_LAUNCHER is honoured by Ninja and Make
# generators, NOT by the Visual Studio / MSBuild generator that
# CMake selects by default on windows-latest. Switch to Ninja so
# cl.exe invocations actually route through sccache. msvc-dev-cmd
# puts the MSVC toolchain on PATH (cl.exe, link.exe, etc.) so
# Ninja can find them.
- name: Setup MSVC environment
uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
with:
arch: x64
- name: Setup hidapi (HID encoder support)
# Needed for the Ulanzi Dial Windows backend (#3239) and the
# existing USB HID encoders (#3171). Without this, HAVE_HIDAPI
# is undefined and the dial code falls back to a stub — CI
# would miss real compile errors in that path. Runs after
# msvc-dev-cmd so the script has cl.exe + link.exe on PATH.
shell: pwsh
run: .\scripts\setup\setup-hidapi.ps1
# zlib is bundled under third_party/zlib (1.3.1) — no vcpkg install
# needed; CMake builds zlibstatic from source. (#2651)
# Verify sccache can actually compile through the backend. Originally
# this guarded against GitHub Actions Cache outages taking the GHA
# backend down mid-build; on the local-dir backend the failure modes are
# duller (unwritable SCCACHE_DIR, a server that won't start), but the
# pre-flight is worth keeping either way — it fails over to a plain
# build instead of failing the whole job.
- name: Test sccache health
id: sccache_health
shell: pwsh
continue-on-error: true
run: |
'int main(){return 0;}' | Out-File -Encoding ASCII test.cpp
# cl.exe's /Fo takes NO colon separator. `/Fo:test.obj` names the
# output file ":test.obj", which is not a legal Windows path, so this
# pre-flight failed on EVERY run from #1963 (2026-04-25) to #4655:
# failed to open file `D:\a\AetherSDR\AetherSDR\:test.obj`:
# The filename, directory name, or volume label syntax is
# incorrect. (os error 123)
# Because the step is continue-on-error and its failure path only
# sets healthy=false, the job went green while quietly dropping
# CMAKE_{C,CXX}_COMPILER_LAUNCHER — "Compile requests 0 / Cache hits
# 0" for three months on a required check. Keep it as /Fo<file>.
sccache cl /c test.cpp /Fotest.obj 2>&1 | Tee-Object -Variable scOut
$exit = $LASTEXITCODE
Remove-Item test.cpp,test.obj -ErrorAction SilentlyContinue
if ($exit -eq 0) {
"healthy=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
Write-Host "sccache OK"
} else {
"healthy=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
Write-Host "::warning::sccache unhealthy (exit $exit) — building without compiler cache"
}
- name: Configure
shell: pwsh
run: |
$launcher = "${{ steps.sccache_health.outputs.healthy }}" -eq "true"
$cargs = @(
"-B","build","-G","Ninja",
"-DCMAKE_BUILD_TYPE=Release",
"-DCMAKE_MSVC_DEBUG_INFORMATION_FORMAT=Embedded",
"-DENABLE_NVIDIA_AFX=ON",
"-DREQUIRE_SERIALPORT=ON",
# ASR OFF for this PR sanity check: its job is Windows-only APP
# regressions (WASAPI, Q_OS_WIN), not ASR. Building whisper/ggml from
# source here linked the large static libs into AetherSDR.exe, and that
# memory-heavy link concurrently starved the small test-exe links on the
# 16 GB runner (link.exe stalling on a different test each run — dstar,
# CAT_Flex — none of which hang on main, which has no ASR). ASR-on-Windows
# is fully built + linked + packaged by the windows-installer workflow,
# so nothing is lost here.
"-DENABLE_ASR=OFF",
# Stated rather than left to the CMake default so the assertion
# below has a request to check against.
"-DAETHER_GPU_SPECTRUM=ON",
"-DCMAKE_TOOLCHAIN_FILE=$env:CMAKE_TOOLCHAIN_FILE",
"-DVCPKG_TARGET_TRIPLET=$env:VCPKG_TARGET_TRIPLET"
)
if ($launcher) {
$cargs += "-DCMAKE_C_COMPILER_LAUNCHER=sccache"
$cargs += "-DCMAKE_CXX_COMPILER_LAUNCHER=sccache"
}
# Tee without `2>&1`: GitHub runs pwsh with $ErrorActionPreference=
# 'Stop', where merging a native command's stderr into the pipeline
# can promote an ordinary CMake warning to a terminating error and
# fail configure on a non-problem. Nothing is lost — every line the
# assertion reads is message(STATUS), which CMake writes to stdout.
# No pipefail equivalent is needed either: $LASTEXITCODE tracks the
# last *native* command (cmake), and Tee-Object is a cmdlet.
& cmake @cargs | Tee-Object -FilePath configure.log
# Same guard as the Linux build job above — see the long note there for
# why it reads configure output rather than CMakeCache.txt. This job is
# the only per-PR coverage the aqtinstall Qt layout gets: it is the layout
# CMakeLists.txt calls out as shipping the private QtGui headers while
# omitting the Qt6GuiPrivate CMake package, so it reaches the fallback
# path that windows-installer.yml depends on at release time.
- name: Assert GPU spectrum rendering actually enabled
shell: pwsh
run: |
if (-not (Select-String -Path configure.log -Pattern 'GPU spectrum rendering enabled' -Quiet)) {
Write-Host "ERROR: -DAETHER_GPU_SPECTRUM=ON but CMake disabled it. Relevant output:"
Select-String -Path configure.log -Pattern 'GPU spectrum|Qt6GuiPrivate|private headers'
exit 1
}
Select-String -Path configure.log -Pattern 'GPU spectrum rendering enabled'
- name: Build Opus (RADE dependency)
# ExternalProject dependency ordering via BUILD_BYPRODUCTS is correct,
# but a separate step gives clearer CI failure attribution and avoids
# any Ninja parallel-build ordering edge cases with IMPORTED targets.
run: cmake --build build --target build_opus -j $env:NUMBER_OF_PROCESSORS
- name: Build
# This job's purpose is to catch Windows-only compile/link regressions
# (WASAPI, Q_OS_WIN paths) that Linux/macOS CI can't see — served by
# linking the app plus the handful of tests run below, NOT the default
# `all` target. Building every *_test.exe relinked the same libraries
# into hundreds of tiny executables for no added Windows coverage, and
# that link tail was stalling for hours on the runner. timeout-minutes
# fails fast (with logs) if it ever hangs again, instead of running to
# the 6-hour job limit.
#
# No job in this file runs the Linux test suite; the full suite runs
# unfiltered on full-suite.yml (every push to main) and again weekly on
# sanitizers.yml. The tests built and run here are the ones whose claim
# is about the MSVC toolchain, which a Linux lane cannot make.
#
# cross_needle_meter_test is the deliberate exception to the
# no-test-tree rule here. #4559's separator assertion failed on Windows
# while passing on Linux, so gating its reformulated assertions on
# Windows is what actually backs their claim to be bit-identical
# across platforms. One small test exe and ~10 s of ctest.
timeout-minutes: 45
run: cmake --build build -j $env:NUMBER_OF_PROCESSORS --target AetherSDR aether-dv-waveform thumbdv_queue_test dstar_model_test digital_voice_waveform_process_test cross_needle_meter_test
# The count pin is NOT redundant with the frozen list, and this is the
# one step that needs it. check_ci_test_gate.py compares names it reads
# out of tests.cmake's SOURCE TEXT; it does not evaluate the CMake
# conditions around them. Wrap one of these four add_test() calls in a
# condition that is false on this platform and the text is unchanged, so
# the frozen list still matches — while the configured build registers
# three, this alternation selects three, and --no-tests=error is happy
# because three is not zero. That is the partial-erosion case the old
# `Total Tests: N` pins existed for (#5232), and it is exactly how
# icom_backend_test once fell off a gate unnoticed (#5405
# second-opinion review). bash rather than the Windows default shell so
# the idiom matches the one this project already used.
- name: Test ThumbDV portability paths
#
# The pattern is repeated rather than held in an env var because
# check_ci_test_gate.py refuses `-R "$VAR"`: an opaque variable is the
# one thing a reviewer of the frozen list cannot read. Repeating it is
# the cost of that rule, and the checker resolves both occurrences to
# the same four names.
shell: bash
run: |
test "$(ctest --test-dir build -N -R "aether_dv_waveform_no_args|thumbdv_queue_test|dstar_model_test|digital_voice_waveform_process_test" | sed -n 's/^Total Tests: //p')" = "4"
ctest --test-dir build -R "aether_dv_waveform_no_args|thumbdv_queue_test|dstar_model_test|digital_voice_waveform_process_test" --no-tests=error --output-on-failure
# cross_needle_meter_test renders widgets; the offscreen platform makes
# its measurements deterministic (verified bit-identical across
# Windows/Linux x86_64/macOS arm64/Linux arm64 — see #4559). Windows is
# the platform on which #4559's separator assertion actually failed,
# which is why it is gated here and not on the Linux job.
# CMakeLists.txt sets QT_QPA_PLATFORM as a test property as well — it is
# repeated here so the requirement is legible at the call site.
- name: Test cross-needle meter geometry
env:
QT_QPA_PLATFORM: offscreen
run: ctest --test-dir build -R "cross_needle_meter_test" --no-tests=error --output-on-failure
# Dependency caches: saved LAST and only from a green main run — see the
# Linux job's Save DeepFilterNet3 step for why the save is not placed
# next to the setup step.
- name: Save Qt
if: github.ref == 'refs/heads/main' && success() && steps.cache-qt-win.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: ${{ runner.workspace }}\Qt
key: ${{ steps.cache-qt-win.outputs.cache-primary-key }}
- name: Save FFTW3
if: github.ref == 'refs/heads/main' && success() && steps.cache-fftw.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: third_party/fftw3
key: ${{ steps.cache-fftw.outputs.cache-primary-key }}
- name: Save DeepFilterNet3
if: github.ref == 'refs/heads/main' && success() && steps.cache-deepfilter.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: third_party/deepfilter
key: ${{ steps.cache-deepfilter.outputs.cache-primary-key }}
- name: sccache stats
if: always()
# Stop the server after reporting: it holds the cache files open, and
# the save step below archives SCCACHE_DIR. Stopping first is what
# guarantees everything this run compiled is flushed to disk and
# actually ends up in the saved entry — which is also why the save is
# an explicit step AFTER this one rather than a post-job hook.
run: |
sccache --show-stats
sccache --stop-server
# Save LAST, and only from a green main build — see the Linux job's
# equivalent step and "Compiler-cache strategy" at the bottom of the file.
- name: Save sccache objects
if: github.ref == 'refs/heads/main' && success()
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: D:\sccache
key: sccache-windows-${{ github.run_id }}
# macOS build check — runs on every PR (no path gating), same rationale
# as check-windows above. Catches Q_OS_MAC / CoreAudio / Cocoa-bridge
# breakage that Linux clang misses, which is otherwise only discovered
# at release tag time by macos-dmg.yml. Build-only (no code signing,
# no DMG, no notarization — those stay in macos-dmg.yml on tag push).
# Apple Silicon runner only; Intel coverage remains release-time-only.
check-macos:
runs-on: macos-15
# One place each for the two versions this job pins, and for the deployment
# target. MACOS_DEPLOYMENT_TARGET is consumed by setup-qtkeychain.sh and
# must match macos-dmg.yml's apple-silicon DEPLOY_TARGET: the two jobs share
# a qtkeychain cache key, so whichever runs first produces the dylib the
# other ships, and a mismatch would put a minos-15 library inside a bundle
# that advertises 14.0.
env:
# QT_VERSION / AQTINSTALL_VERSION are workflow-level env (top of file).
MACOS_DEPLOYMENT_TARGET: '14.0'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Install dependencies via Homebrew
# Mirrors macos-dmg.yml's apple-silicon path — which no longer takes Qt
# from Homebrew, so neither does this. Leaving qt@6 here would put the
# per-PR macOS check on a rolling Qt (6.11.1 today) while the DMG ships
# 6.8.3, i.e. CI green would say nothing about the artifact.
run: |
brew install ninja fftw portaudio hidapi librtlsdr \
autoconf automake libtool
# ~1.3 GB downloaded on every PR otherwise, and it showed: this job went
# from ~6 min to ~18 min the first time it took Qt from aqt. aqt has no
# caching wrapper, so cache the tree explicitly (check-windows now does
# the same). Keyed on everything that changes its contents — the Qt
# version, the module set, and the aqt version that resolved them — so
# the entry is written once and then just restored. Saved from main only;
# see the Linux job's DeepFilterNet3 step for why.
- name: Restore Qt
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
id: cache-qt-mac
with:
path: ${{ github.workspace }}/Qt
key: qt-${{ env.QT_VERSION }}-macos-clang64-aqt${{ env.AQTINSTALL_VERSION }}-mm.ws.sp.st
- name: Install Qt LTS via aqtinstall
if: steps.cache-qt-mac.outputs.cache-hit != 'true'
run: |
set -o pipefail
# aqt goes in a venv, and is pinned: it is the tool that decides what
# Qt lands here, so an unreviewed aqt upgrade is an unreviewed change
# to the Qt the per-PR macOS check builds against — the same argument
# this job exists to make about Qt itself. Matches
# .github/docker/Dockerfile, which pins for that reason.
python3 -m venv "$RUNNER_TEMP/aqtvenv"
"$RUNNER_TEMP/aqtvenv/bin/pip" install -q "aqtinstall==${AQTINSTALL_VERSION}" "py7zr==${PY7ZR_VERSION}"
"$RUNNER_TEMP/aqtvenv/bin/aqt" install-qt mac desktop "$QT_VERSION" clang_64 \
-m qtmultimedia qtwebsockets qtserialport qtshadertools \
--outputdir "${{ github.workspace }}/Qt"
# Separate from the install so it also runs on a cache hit.
- name: Export Qt environment
run: |
set -o pipefail
QT_ROOT="${{ github.workspace }}/Qt/$QT_VERSION/macos"
if [ ! -x "$QT_ROOT/bin/qmake" ]; then
# A bad entry on main is only clearable by hand: the key is a
# content hash nothing re-saves, and no sweeper matches qt-* there.
echo "ERROR: no qmake at $QT_ROOT — aqt layout changed, or a stale cache entry"
echo " (clear a stale entry: gh cache delete --key <the Restore Qt key> --ref refs/heads/main)"
ls -la "${{ github.workspace }}/Qt/$QT_VERSION/" || true
exit 1
fi
# CMAKE_PREFIX_PATH matters beyond the Configure step:
# setup-qtkeychain.sh locates Qt via CMAKE_PREFIX_PATH, then Qt6_DIR,
# then qmake on PATH. Exporting only QT_ROOT would leave it with none
# of the three, and it would exit "Qt6 not found" before building.
# PATH goes through GITHUB_PATH rather than GITHUB_ENV so it prepends
# instead of freezing this step's PATH for every later step.
{
echo "QT_ROOT=$QT_ROOT"
echo "CMAKE_PREFIX_PATH=$QT_ROOT"
} >> "$GITHUB_ENV"
echo "$QT_ROOT/bin" >> "$GITHUB_PATH"
"$QT_ROOT/bin/qmake" -query QT_VERSION
# Same guard as macos-dmg.yml, same history: #711 removed aqt from macOS
# because two discoverable Qt trees produced Qt6GuiPrivate and linker
# failures, and #812 hit it again on Intel. The second Qt arrives
# transitively — brew's qtkeychain depends on qtbase — so "we didn't
# install Qt" is not sufficient. CMAKE_PREFIX_PATH below still includes
# $(brew --prefix) for fftw/portaudio/hidapi.
- name: Assert no Homebrew Qt is discoverable
run: |
set -o pipefail
if brew list --formula 2>/dev/null | grep -qE '^(qt|qt@[0-9]+|qtbase)$'; then
echo "ERROR: a Homebrew Qt is installed alongside the aqt Qt:"
brew list --formula | grep -E '^(qt|qt@[0-9]+|qtbase)$'
echo "Two discoverable Qt trees is the #711 / #812 failure mode."
exit 1
fi
if [ -f "$(brew --prefix)/lib/cmake/Qt6/Qt6Config.cmake" ]; then
echo "ERROR: a Qt6 CMake config exists under $(brew --prefix)"
exit 1
fi
echo "No Homebrew Qt present; the aqt tree at $QT_ROOT is the only one."
- name: Restore DeepFilterNet3
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
id: cache-deepfilter
with:
path: third_party/deepfilter
key: deepfilter-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('scripts/setup/setup-deepfilter.sh') }}
- name: Setup DeepFilterNet3 (DFNR)
if: steps.cache-deepfilter.outputs.cache-hit != 'true'
run: bash scripts/setup/setup-deepfilter.sh
- name: Restore qtkeychain
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
id: cache-qtkeychain-mac
with:
path: third_party/qtkeychain
# Deliberately identical to macos-dmg.yml's key: both jobs build the
# same source against the same Qt at the same deployment target, so
# sharing the entry is the point. That makes every input that changes
# the dylib part of the key — Qt version, deployment target, script
# hash — because this job runs on push:main and default-branch caches
# are visible to every ref, so the entry the *tag* build restores is
# normally the one this job wrote.
key: qtkeychain-macos-arm64-qt${{ env.QT_VERSION }}-deploy${{ env.MACOS_DEPLOYMENT_TARGET }}-${{ hashFiles('scripts/setup/setup-qtkeychain.sh') }}
# Dropping Homebrew's qtkeychain along with its Qt would have quietly
# removed credential-persistence compile coverage from every PR, since
# find_package(Qt6Keychain QUIET) just compiles it out — the #3639 shape.
# Build it from source against the same aqt Qt the DMG uses, and pair it
# with REQUIRE_KEYCHAIN=ON at configure so a regression is a red run.
- name: Setup qtkeychain (SmartLink credential persistence)
if: steps.cache-qtkeychain-mac.outputs.cache-hit != 'true'
run: bash scripts/setup/setup-qtkeychain.sh
- name: Set up ccache
uses: hendrikmuhs/ccache-action@f09c25b45002a07be2955cbe52e8cee55643f89d
with:
key: ccache-macos-ci
max-size: 500M
# Same save-on-main split as the Linux and Windows jobs, expressed
# through this action's own `save` input rather than a restore/save
# pair. Restore stays on (default) so PRs read main's entry.
#
# append-timestamp stays at its default (true) deliberately: the
# action needs a unique key per save because actions/cache entries are
# immutable, so pinning it to the bare `ccache-macos-ci` would make
# every save after the first a silent no-op and freeze the cache.
#
# KNOWN GAP vs the other two jobs: this input is evaluated when the
# step runs, and the action saves from its own post-job hook, so there
# is no `success()` half — a failed main build here still writes an
# entry. Tolerated rather than worked around: the cost is a sparser
# cache until the next green main run, not a wrong one, and at 500M on
# the lighter macOS configuration that is the smallest of the three.
save: ${{ github.ref == 'refs/heads/main' }}
- name: Ensure offline Metal toolchain
# Build-time ASR kernel compilation (#4535) needs the offline Metal
# compiler at configure. Xcode 16 bundles it (probe succeeds, no-op);
# Xcode 26 ships it as a separate download, so this step keeps the job
# working across a runner-image Xcode bump.
run: |
xcrun -sdk macosx metal --version >/dev/null 2>&1 || \
sudo xcodebuild -downloadComponent MetalToolchain
- name: Configure
run: |
# pipefail so the tee below can't mask a cmake failure — GitHub's
# default bash is -e without it. The Linux build job cannot do this
# and redirects instead: it runs in the CI container, which has no
# bash, so GitHub falls back to dash. macOS runners have bash.
# AETHER_GPU_SPECTRUM is stated rather than defaulted for the same
# reason as there — the assertion needs a request to check against.
set -o pipefail
cmake -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_C_COMPILER_LAUNCHER=ccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache \
-DCMAKE_PREFIX_PATH="$QT_ROOT;$(brew --prefix)" \
-DAETHER_GPU_SPECTRUM=ON \
-DREQUIRE_KEYCHAIN=ON \
-DMQTT_TLS=OFF 2>&1 | tee configure.log
# Same guard as the Linux build job above. -iE rather than the -i "a\|b"
# form: this runs on the macOS runner's BSD grep, which has no BRE
# alternation and would search for the literal "a|b".
#
# This assertion is the one that caught the aqt Qt having no Qt6GuiPrivate
# CMake package where Homebrew's had one — see the CMakeLists.txt macOS
# framework-layout probe. Homebrew Qt was carrying this leg; the pin
# exposed that, which is exactly what a guard is for.
- name: Assert GPU spectrum rendering actually enabled
run: |
grep -q "GPU spectrum rendering enabled" configure.log || {
echo "ERROR: -DAETHER_GPU_SPECTRUM=ON but CMake disabled it. Relevant output:"
grep -iE "GPU spectrum|Qt6GuiPrivate|private headers" configure.log || true
exit 1
}
grep "GPU spectrum rendering enabled" configure.log
- name: Build Opus (RADE dependency)
run: cmake --build build --target build_opus -j$(sysctl -n hw.ncpu)
- name: Build
run: cmake --build build -j$(sysctl -n hw.ncpu)
- name: Run macOS MNR regression test
run: ctest --test-dir build -R '^mac_nr_filter_test$' --no-tests=error --output-on-failure
- name: Run ASR GPU probe test (#4535 regression)
# arm64 runner = Apple Silicon: exercises the real Metal device init and
# embedded precompiled-metallib load on every PR. On the pre-#4553 code
# this call could invoke the runtime shader compiler and hang.
#
# AETHER_ASR_EXPECT_PRECOMPILED=1 makes the test fail (rather than pass
# quietly) if no Metal device is enumerated or if the build fell back to
# the embedded-source runtime-compile path — the CMake toolchain probe
# degrades with only a message(WARNING), so without this the gate could
# be green while guarding nothing. -V keeps the device list and the
# library-path line in the log, where a reviewer can actually see them.
env:
AETHER_ASR_EXPECT_PRECOMPILED: "1"
run: ctest --test-dir build -R asr_gpu_probe_test -V --no-tests=error
- name: Compile ASR Metal kernels with the Intel-DMG flag set
# This job passes no CMAKE_OSX_DEPLOYMENT_TARGET, so it always takes the
# metal3.1 branch of the ASR precompile logic. macos-dmg.yml builds the
# Intel DMG at 13.0, which takes the other branch — metal3.0 plus the
# props.has_bfloat clamp, because bf16 kernels need >= 3.1. Nothing else
# in CI compiles that combination, so without this step it would first be
# exercised at tag time, on a release workflow, with REQUIRE_ASR_GPU=ON.
#
# Recompiling the already-merged kernel source is enough: the flags are
# the whole risk (a version-min / -std mismatch is a hard compile error),
# and the merged source is a build artifact of the step above.
run: |
src=build/third_party/whisper.cpp/ggml/src/ggml-metal/autogenerated/ggml-metal-embed.metal
test -f "$src" || { echo "merged kernel source missing at $src"; exit 1; }
xcrun -sdk macosx metal -O3 -mmacosx-version-min=13.0 -std=metal3.0 \
-DGGML_METAL_EMBED_LIBRARY=1 -DGGML_METAL_HAS_BF16=1 -c "$src" -o - |
xcrun -sdk macosx metallib - -o "$RUNNER_TEMP/ggml-metal-intel-dmg.metallib"
ls -l "$RUNNER_TEMP/ggml-metal-intel-dmg.metallib"
# Dependency caches: saved LAST and only from a green main run — see the
# Linux job's Save DeepFilterNet3 step for why the save is not placed
# next to the setup step. The ccache-action above saves from its own
# post-job hook (with the KNOWN GAP its comment describes); these three
# are ordinary steps and do get the `success()` half.
#
# qtkeychain: macos-dmg.yml restores this same key on a tag ref and
# cannot read a PR's copy anyway; the entry it normally finds is the one
# this job wrote from main.
- name: Save Qt
if: github.ref == 'refs/heads/main' && success() && steps.cache-qt-mac.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: ${{ github.workspace }}/Qt
key: ${{ steps.cache-qt-mac.outputs.cache-primary-key }}
- name: Save DeepFilterNet3
if: github.ref == 'refs/heads/main' && success() && steps.cache-deepfilter.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: third_party/deepfilter
key: ${{ steps.cache-deepfilter.outputs.cache-primary-key }}
- name: Save qtkeychain
if: github.ref == 'refs/heads/main' && success() && steps.cache-qtkeychain-mac.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with: