-
-
Notifications
You must be signed in to change notification settings - Fork 35
1349 lines (1312 loc) · 68.2 KB
/
Copy pathcode-quality.yml
File metadata and controls
1349 lines (1312 loc) · 68.2 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: Code Quality
# Tech-debt prevention gates that run on every PR to master.
# Each lane is independent so failures are isolated and parallelised. The
# `quality-gate` job at the bottom is the single required check.
#
# Output convention for every step:
# 1. The step name is the plain-English explanation of WHAT is being checked.
# 2. The script body wraps the raw tool output in ::group::/::endgroup::
# collapsibles so reviewers see a pass/fail summary line first.
# 3. The final echo is a one-line human + LLM readable verdict.
#
# Companion to .github/workflows/main.yml — that workflow handles
# correctness (build, type-check, tests). This workflow enforces code
# hygiene to prevent low-quality / LLM-generated code from accumulating.
on:
pull_request:
branches:
- master
push:
branches:
- master
workflow_dispatch:
inputs:
force_github:
description: Run every lane on GitHub-hosted runners (exercises the fallback path)
type: boolean
default: false
runner_label:
description: Home runner label to probe (default gaia-home-lint; a new pool under a temporary label can be proven before it takes real traffic)
type: string
default: gaia-home-lint
cpu_tokens:
description: Override GAIA_CPU_TOKENS for the host CPU governor (empty = physical-core default; a large value like 999 effectively disables it for before/after measurement)
type: string
default: ""
# Per-SHA group; superseded runs are cancelled by select-runner via the API
# (scripts/ci/runner.sh cancel-superseded) so a new push never waits on the old
# run's cancellation — see main.yml for the measured reason.
concurrency:
group: code-quality-${{ github.workflow }}-${{ github.head_ref || github.ref }}-${{ github.sha }}
cancel-in-progress: true
permissions:
contents: read
env:
# Host CPU governor pool size (scripts/ci/lib/cpu-slots.sh); shared across the
# box with main.yml. Empty on push/PR → physical-core default; a dispatch may
# override it. The mutation lane is the heavy consumer here.
GAIA_CPU_TOKENS: ${{ inputs.cpu_tokens || '' }}
jobs:
# ── Runner selection: home box when online, GitHub otherwise ──────────────
# Every compute lane below runs on the 16-core home server through this
# probe (scripts/ci/runner.sh select): queued behind a busy pool (setup
# there is ~30x cheaper than on GitHub), falling back to ubuntu-latest in
# under 40s when the box is offline or unreachable, and always for fork
# PRs. The gate and the pure-network scanners stay on GitHub — they gain
# nothing from cores.
select-runner:
name: Select runner
runs-on: ubuntu-latest
timeout-minutes: 2
permissions:
contents: read
actions: write # cancel superseded runs
outputs:
runner: ${{ steps.pick.outputs.runner }}
runner_label: ${{ steps.pick.outputs.runner_label }}
is_self_hosted: ${{ steps.pick.outputs.is_self_hosted }}
steps:
# The default branch, NOT the PR head. This job hands HOMELAB_GH_PAT to
# `scripts/ci/runner.sh select` through the composite below, and on a
# `pull_request` event the head ref is code the PR author controls — a
# checkout of it would run their script with our secret. Pinning the
# checkout to the default branch means the composite and the script both
# come from a reviewed commit. The cost: a PR changing runner.sh does not
# exercise the change in THIS job (it does everywhere else) — the right
# trade for a job whose only purpose is routing.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.repository.default_branch }}
fetch-depth: 1
persist-credentials: false
- name: Cancel superseded runs of this branch
env:
GITHUB_TOKEN: ${{ github.token }}
run: bash scripts/ci/runner.sh cancel-superseded
- id: pick
uses: ./.github/actions/select-runner
with:
# Lint lanes use their own runner pool (instances 12-20, LINT_RUNNER_START
# in gaia-infra:self-hosted-runner/setup.sh) so they can neither take
# main.yml's slots nor oversubscribe its CPUs — measured 2026-08-28:
# 7 lint jobs + 2 mutation shards held every slot and the pipeline's
# detect job queued 280 s.
runner-label: ${{ inputs.runner_label || 'gaia-home-lint' }}
force-github: ${{ inputs.force_github && 'true' || 'false' }}
github-token: ${{ secrets.HOMELAB_GH_PAT || github.token }}
# --- Change detection (no toolchain install) -----------------------------
# Unified with main.yml detect: uses nx-set-shas + nx show projects --affected
# with same base (origin/master via NX_BASE/NX_HEAD) so both workflows
# agree on what "affected" means. Push/dispatch → __FULL__ semantics via
# workflow_dispatch branch; PRs use --affected. No changes.sh grep.
changes:
name: Detect changed languages
needs: [select-runner]
runs-on: ${{ fromJSON(needs.select-runner.outputs.runner) }}
timeout-minutes: 5
permissions:
contents: read
pull-requests: read
actions: read
outputs:
has_python: ${{ steps.detect.outputs.has_python }}
has_typescript: ${{ steps.detect.outputs.has_typescript }}
has_alert_rules: ${{ steps.detect.outputs.has_alert_rules }}
python_projects: ${{ steps.detect.outputs.python_projects }}
typescript_projects: ${{ steps.detect.outputs.typescript_projects }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
clean: ${{ runner.environment != 'self-hosted' }}
fetch-depth: 0
persist-credentials: false
- uses: nrwl/nx-set-shas@afb73a62d26e41464e9254689e1fd6122ee683c1 # v5.0.1
with:
main-branch-name: master
- uses: ./.github/actions/setup-node-pnpm
- name: Detect affected projects
id: detect
run: |
set -euo pipefail
normalize() { tr -d '[]"' | tr -s ' \n' ',' | sed 's/^,//; s/,$//'; }
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
PY=$(pnpm exec nx show projects --projects 'tag:scope:python' | normalize)
TS=$(pnpm exec nx show projects --projects 'tag:scope:typescript' | normalize)
ALERTS=true
else
PY=$(pnpm exec nx show projects --affected --projects 'tag:scope:python' | normalize)
TS=$(pnpm exec nx show projects --affected --projects 'tag:scope:typescript' | normalize)
# Alert rules are path-scoped, not tag-scoped: derive from affected files
# using the same base nx uses (NX_BASE). Falls back to origin/master.
BASE="${NX_BASE:-origin/master}"
HEAD="${NX_HEAD:-HEAD}"
# No fetch needed: the checkout above is fetch-depth: 0, so every
# remote branch (and nx-set-shas' base SHA) is already local.
AFFECTED_FILES=$(git diff --name-only "$BASE...$HEAD" 2>/dev/null || git diff --name-only "origin/master...HEAD" 2>/dev/null || echo "__FULL__")
if [[ "$AFFECTED_FILES" == "__FULL__" ]]; then
ALERTS=true
elif echo "$AFFECTED_FILES" | grep -qE '^(infra/docker/observability/grafana/provisioning/alerting/|tools/alert-rules/|config/pint\.hcl$)'; then
ALERTS=true
else
ALERTS=false
fi
fi
echo "python_projects=$PY" >> "$GITHUB_OUTPUT"
echo "typescript_projects=$TS" >> "$GITHUB_OUTPUT"
echo "has_python=$([[ -n "$PY" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "has_typescript=$([[ -n "$TS" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "has_alert_rules=$ALERTS" >> "$GITHUB_OUTPUT"
echo "python: ${PY:-none} | typescript: ${TS:-none} | alert-rules: $ALERTS"
echo "Affected Python: $PY"
echo "Affected TypeScript: $TS"
# --- Biome lint + format -------------------------------------------------
# ── Scheduling on the home box ───────────────────────────────────────────
# One push runs this workflow and Quality Checks on the same 16 threads.
# Launching all 23 jobs at once made every job run at ~⅓ speed (measured
# 2026-08-29: mutation shards 104 s -> 156-179 s, jscpd 91 -> 177 s) with
# the long pole — mutation plan -> shards -> gate — starved. The long and
# CPU-heavy jobs (mutation shards, jscpd, Python static, mypy, knip) start
# first; the ~30 s checks below wait for Python static, mypy and knip (done
# by ~145 s) and fill in alongside jscpd and the shards — not behind jscpd,
# which is the slowest lint job (measured: gating on it pushed the light
# wave to t=176 s and the gate to 246 s). `!cancelled()` keeps them running when a heavy job fails or is
# skipped, so no check is ever hidden by ordering. On GitHub-hosted fallback
# this serialises two waves (~+60 s); the box is the primary target.
biome:
name: Biome lint + format
needs: [select-runner, changes, python-static, python-mypy, dead-code]
if: ${{ !cancelled() && (needs.changes.outputs.has_typescript == 'true') }}
runs-on: ${{ fromJSON(needs.select-runner.outputs.runner) }}
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
clean: ${{ runner.environment != 'self-hosted' }}
fetch-depth: 0
persist-credentials: false
- uses: ./.github/actions/setup-node-pnpm
- name: Lint and format check across all TS/JS/JSON/CSS files
run: |
echo "::group::Biome check"
# Drop top-level paths that biome.json explicitly excludes via `!!`
# negation (apps/api, apps/voice-agent, apps/web/public, infra, docs,
# .github, etc.). Biome errors out with exit 1 when every input path
# is excluded by config, so the lane must not feed it those paths
# in the first place. Keep this list in sync with biome.json
# `files.includes` negations.
FILES=$(scripts/ci/changes.sh files ts tsx js jsx json jsonc css \
| grep -Ev '^(apps/api|apps/voice-agent|apps/web/public|infra|docs|\.github|\.claude|\.agents|\.husky|\.yarn|\.vscode|\.idea|\.mise|\.opencode|\.worktrees|\.jscpd-report)/' || true)
if [ "$FILES" = "__FULL__" ]; then
FILES="."
fi
if [ -z "$FILES" ]; then
echo "No matching changed files — skipping."
echo "::endgroup::"
exit 0
fi
# One run, captured then inspected — never `biome | grep -q`: grep
# closes the pipe on its first match, SIGPIPE-kills biome, and the
# false exit code under pipefail makes the gate below report a pass.
# $FILES is unquoted on purpose: it is a newline-delimited path list
# and must word-split into one argument per file.
BIOME_STATUS=0
BIOME_OUT=$(pnpm exec biome check $FILES 2>&1) || BIOME_STATUS=$?
echo "$BIOME_OUT"
echo "::endgroup::"
# Dead-suppression gate: biome emits `suppressions/unused` as a
# fixed-severity WARNING (not a configurable rule), so warnings stay
# non-blocking — except this one category. A suppression comment that
# no longer masks anything is exactly the rot the suppression-hygiene
# lane exists to prevent; grep biome's own verdict for it.
case "$BIOME_OUT" in
*"suppressions/unused"*)
echo "::error::dead biome-ignore found — delete it or fix what it suppressed"
exit 1 ;;
esac
[ "$BIOME_STATUS" -eq 0 ] || exit "$BIOME_STATUS"
echo "Biome: OK (0 errors; dead-suppression gate passed)"
# --- Workspace dependency hygiene ---------------------------------------
deps:
name: Workspace dependency hygiene
needs: [select-runner, changes, python-static, python-mypy, dead-code]
if: ${{ !cancelled() && (needs.changes.outputs.has_typescript == 'true') }}
runs-on: ${{ fromJSON(needs.select-runner.outputs.runner) }}
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
clean: ${{ runner.environment != 'self-hosted' }}
fetch-depth: ${{ runner.environment == 'self-hosted' && '0' || '1' }}
persist-credentials: false
- uses: ./.github/actions/setup-node-pnpm
- name: All workspace packages agree on dep versions (syncpack)
run: |
trap 'echo "::error::repro: pnpm exec syncpack lint --config config/.syncpackrc.json"' ERR
echo "::group::syncpack lint"
pnpm exec syncpack lint --config config/.syncpackrc.json
echo "::endgroup::"
echo "### Deps (syncpack) — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
echo "syncpack: OK (dep versions and semver ranges consistent)"
- name: Workspace topology is valid (manypkg)
run: |
trap 'echo "::error::repro: pnpm exec manypkg check"' ERR
echo "::group::manypkg check"
pnpm exec manypkg check
echo "::endgroup::"
echo "### Deps (manypkg) — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
echo "manypkg: OK (workspace structure valid)"
- name: package.json shape conforms (npm-package-json-lint)
run: |
trap 'echo "::error::repro: pnpm run quality:pkg-json"' ERR
echo "::group::package.json lint"
pnpm run quality:pkg-json
echo "::endgroup::"
echo "### Deps (pkg-json) — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
echo "pkg-json: OK (all package.json files conform to schema)"
# --- Circular dependency check ------------------------------------------
circular:
name: No circular imports
needs: [select-runner, changes, python-static, python-mypy, dead-code]
if: ${{ !cancelled() && (needs.changes.outputs.has_typescript == 'true') }}
runs-on: ${{ fromJSON(needs.select-runner.outputs.runner) }}
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
clean: ${{ runner.environment != 'self-hosted' }}
fetch-depth: ${{ runner.environment == 'self-hosted' && '0' || '1' }}
persist-credentials: false
- uses: ./.github/actions/setup-node-pnpm
- name: Circular dependency scan (Biome)
run: |
trap 'echo "::error::repro: pnpm run quality:circular"' ERR
echo "::group::biome lint --only=suspicious/noImportCycles"
pnpm run quality:circular
echo "::endgroup::"
echo "### Circular deps — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
echo "Circular deps: OK (no cycles found)"
# --- File size limit (per-extension + allowlist + hard cap) -------------
file-size:
name: File size limits
needs: [select-runner, changes, python-static, python-mypy, dead-code]
if: ${{ !cancelled() && (needs.changes.outputs.has_typescript == 'true') }}
runs-on: ${{ fromJSON(needs.select-runner.outputs.runner) }}
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
clean: ${{ runner.environment != 'self-hosted' }}
fetch-depth: 0
persist-credentials: false
- uses: ./.github/actions/setup-node-pnpm
- name: Per-file line count (default 400, relaxed 700, hard cap 1200)
run: |
trap 'echo "::error::repro: pnpm run quality:size"' ERR
echo "::group::File size scan"
FILES=$(scripts/ci/changes.sh files ts tsx js jsx mjs cjs)
if [ "$FILES" = "__FULL__" ]; then
pnpm run quality:size
elif [ -z "$FILES" ]; then
echo "No matching changed files — skipping."
else
CHANGED_FILES="$FILES" node scripts/ci/checks.mjs file-sizes
fi
echo "::endgroup::"
echo "### File size — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
echo "File size: OK (no hard-cap violations)"
# --- Types must live in dedicated type files ----------------------------
types-location:
name: Types live in dedicated *.types.ts files
needs: [select-runner, changes, python-static, python-mypy, dead-code]
if: ${{ !cancelled() && (needs.changes.outputs.has_typescript == 'true') }}
runs-on: ${{ fromJSON(needs.select-runner.outputs.runner) }}
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
clean: ${{ runner.environment != 'self-hosted' }}
fetch-depth: 0
persist-credentials: false
- uses: ./.github/actions/setup-node-pnpm
- name: Type / interface / enum location enforcer
run: |
trap 'echo "::error::repro: node scripts/ci/checks.mjs types-location --enforce"' ERR
echo "::group::types-location check"
FILES=$(scripts/ci/changes.sh files ts tsx)
if [ "$FILES" = "__FULL__" ]; then
node scripts/ci/checks.mjs types-location --enforce
elif [ -z "$FILES" ]; then
echo "No matching changed files — skipping."
else
CHANGED_FILES="$FILES" node scripts/ci/checks.mjs types-location --enforce
fi
echo "::endgroup::"
echo "### Types location — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
echo "Type colocation: OK (no file exports > 3 types unless in *.types.ts)"
# --- Components-per-file ------------------------------------------------
components-per-file:
name: At most 2 React components per file
needs: [select-runner, changes, python-static, python-mypy, dead-code]
if: ${{ !cancelled() && (needs.changes.outputs.has_typescript == 'true') }}
runs-on: ${{ fromJSON(needs.select-runner.outputs.runner) }}
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
clean: ${{ runner.environment != 'self-hosted' }}
fetch-depth: 0
persist-credentials: false
- uses: ./.github/actions/setup-node-pnpm
- name: Components-per-file enforcer
run: |
trap 'echo "::error::repro: pnpm run quality:components"' ERR
echo "::group::components-per-file check"
FILES=$(scripts/ci/changes.sh files tsx)
if [ "$FILES" = "__FULL__" ]; then
pnpm run quality:components
elif [ -z "$FILES" ]; then
echo "No matching changed files — skipping."
else
CHANGED_FILES="$FILES" node scripts/ci/checks.mjs components-per-file
fi
echo "::endgroup::"
echo "### Components per file — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
echo "Components: OK (no file declares > 2 React components)"
# --- Copy-paste detection -----------------------------------------------
duplicates:
name: Copy-paste detection (jscpd)
needs: [select-runner, changes]
if: needs.changes.outputs.has_typescript == 'true'
runs-on: ${{ fromJSON(needs.select-runner.outputs.runner) }}
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
clean: ${{ runner.environment != 'self-hosted' }}
fetch-depth: 0
persist-credentials: false
- uses: ./.github/actions/setup-node-pnpm
- name: Copy-paste detection (jscpd, fails over 3% on changed lines)
run: |
trap 'echo "::error::repro: pnpm run quality:dupe"' ERR
echo "::group::jscpd"
# No fetch: the checkout above is fetch-depth: 0, so
# origin/$GITHUB_BASE_REF — the ref `checks.mjs duplication` diffs
# against — is already local.
pnpm run quality:dupe
echo "::endgroup::"
echo "### Duplicates (jscpd) — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
echo "Duplicates: OK (<= 3% on changed lines)"
# --- Published package hygiene ------------------------------------------
package-hygiene:
name: Published package hygiene (publint + attw)
needs: [select-runner, changes, python-static, python-mypy, dead-code]
if: ${{ !cancelled() && (needs.changes.outputs.has_typescript == 'true') }}
runs-on: ${{ fromJSON(needs.select-runner.outputs.runner) }}
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
clean: ${{ runner.environment != 'self-hosted' }}
fetch-depth: ${{ runner.environment == 'self-hosted' && '0' || '1' }}
persist-credentials: false
- uses: ./.github/actions/setup-node-pnpm
- name: publint — validate packages/cli npm metadata
run: |
trap 'echo "::error::repro: pnpm run quality:publint"' ERR
echo "::group::publint packages/cli"
pnpm run quality:publint
echo "::endgroup::"
echo "### Package hygiene (publint) — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
echo "publint: OK (packages/cli npm metadata is valid)"
- name: are-the-types-wrong — validate type exports
run: |
trap 'echo "::error::repro: pnpm run quality:attw"' ERR
echo "::group::attw packages/cli"
OUTPUT=$(pnpm run quality:attw 2>&1)
echo "$OUTPUT"
if echo "$OUTPUT" | grep -q "This package does not contain types"; then
echo "::endgroup::"
echo "attw: OK (packages/cli is a CLI binary — no exported types is expected)"
else
echo "::endgroup::"
echo "### Package hygiene (attw) — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
echo "attw: OK (type exports resolve across CJS/ESM/node16/bundler)"
fi
# Known advisories at or above `high` in the production dependency graph
# fail the lane unless allowlisted with a reason and an expiry in
# config/pnpm-audit-allowlist.json (an expired entry fails the lane too).
- name: pnpm audit (production dependencies, high and above)
run: bash scripts/ci/audit.sh pnpm
# --- TypeScript strict type-check ---------------------------------------
type-check:
name: TypeScript strict type-check (all workspaces)
needs: [select-runner, changes, python-static, python-mypy, dead-code]
if: ${{ !cancelled() && (needs.changes.outputs.has_typescript == 'true') }}
runs-on: ${{ fromJSON(needs.select-runner.outputs.runner) }}
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
clean: ${{ runner.environment != 'self-hosted' }}
fetch-depth: 0
persist-credentials: false
- uses: ./.github/actions/setup-node-pnpm
- name: tsc --noEmit across web, desktop, mobile, cli, bots
run: |
echo "::group::nx type-check"
if [ -n "${GITHUB_BASE_REF:-}" ]; then
# No fetch: the checkout above is fetch-depth: 0, so
# origin/$GITHUB_BASE_REF is already local for nx affected.
# Exclude Python projects (api, voice-agent): their type-check
# runs mypy via uv, which isn't installed in this Node-only job, and
# Python typing is already enforced by the dedicated python-mypy lane.
pnpm exec nx affected -t type-check --base=origin/$GITHUB_BASE_REF --exclude=api,voice-agent
else
pnpm exec nx run-many -t type-check --projects=web,desktop,mobile,cli,bot-discord,bot-slack,bot-telegram,bot-whatsapp,bot-imessage
fi
echo "::endgroup::"
echo "TypeScript: OK (all affected workspaces pass strict tsc)"
# --- Python static analysis (ruff + xenon + interrogate + security) ------
# One job, not four: each of these tools is seconds of work behind ~40s of
# runner boot + checkout + uv install, so four separate lanes spent most of
# their wall-clock on setup and burned four runners to do it. They share the
# exact same environment (uv 0.10.6, no dependency install), so they share
# one job.
#
# Failure isolation is preserved by `continue-on-error` on every tool step
# plus the aggregating verdict at the end: one red tool no longer hides the
# other five, which is the only thing separate jobs bought us here.
python-static:
name: Python static (ruff + custom lints + xenon + interrogate + bandit + pip-audit)
needs: [select-runner, changes]
if: needs.changes.outputs.has_python == 'true'
runs-on: ${{ fromJSON(needs.select-runner.outputs.runner) }}
# The six tools now run serially in one job, so the cap is the sum of what
# the four old lanes allowed themselves in parallel (10 + 5 + 5 + 10),
# halved for headroom. A too-tight cap surfaces as a `cancelled` lane,
# which fails the quality gate for a reason nobody can read.
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
clean: ${{ runner.environment != 'self-hosted' }}
fetch-depth: 0
persist-credentials: false
- uses: ./.github/actions/setup-uv
- name: ruff check + format (PL, RUF, FAST, S, B, A, COM, C4, DTZ, ISC, RET, SIM, ARG, PTH, TRY, PERF, UP, FURB, LOG, G + more)
id: ruff
continue-on-error: true
# Pinned to the uv.lock ruff version — bump both together. Unpinned,
# a ruff release can flip this lane red with zero code changes
# (0.16.0 shipped ISC004/RUF036 four minutes before a run here).
run: |
echo "::group::ruff check + format --check"
FILES=$(scripts/ci/changes.sh files py)
if [ "$FILES" = "__FULL__" ]; then
# Whole repo, not a list of roots. The old list was
# "apps/api apps/voice-agent libs/shared/py", which silently excluded
# tools/, .agents/skills/, apps/web/scripts/ and docs/scripts/ — and
# is how libs/shared/py went unlinted long enough to accumulate 15
# findings after a rule was switched on repo-wide. A new app or lib
# is now covered the moment it exists instead of when someone
# remembers to add it here. Exclusions live in [tool.ruff]
# extend-exclude, where they are visible and reviewable.
uvx --no-build ruff@0.14.13 check .
uvx --no-build ruff@0.14.13 format --check .
elif [ -z "$FILES" ]; then
echo "No matching changed files — skipping."
else
uvx --no-build ruff@0.14.13 check $FILES
uvx --no-build ruff@0.14.13 format --check $FILES
fi
echo "::endgroup::"
echo "ruff: OK (strict rule set passes; every file is already formatted)"
- name: Custom Python AST lints (route contract, no service classes, wide events, repository boundaries)
id: custom-lints
continue-on-error: true
run: |
echo "::group::custom lints"
python3 tools/lints/run.py apps/api/app
echo "::endgroup::"
echo "Custom lints: OK (4 rules pass)"
- name: Ruff escape-hatch whys (ignore / per-file-ignores must be justified)
id: ignore-whys
continue-on-error: true
# Also a pre-commit hook. Every rule switched off in pyproject.toml
# must carry a why-comment beside it — an exemption without a stated
# reason is a hole, not a decision.
run: |
echo "::group::ignore whys"
python3 tools/lints/check_ignore_whys.py
echo "::endgroup::"
echo "### Escape-hatch whys — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
echo "ignore-whys: OK (every ruff/mypy escape hatch is documented)"
- name: PLR complexity ratchet (touch a grandfathered file, fix it now)
id: plr-complexity
continue-on-error: true
# PLR0911/0912/0913/0915 are in ruff's ignore list so the lane above
# doesn't double-report them -- this script is what actually enforces
# them, via tools/lints/plr_complexity_baseline.txt. See
# tools/lints/README.md#plr-complexity-ratchet.
run: |
echo "::group::plr complexity ratchet"
python3 tools/lints/check_plr_complexity.py
echo "::endgroup::"
echo "plr-complexity-ratchet: OK"
- name: Complexity scan (max-absolute F, max-modules E, max-average B)
id: xenon
continue-on-error: true
run: |
trap 'echo "::error::repro: uvx xenon --max-absolute F --max-modules E --max-average B"' ERR
echo "::group::xenon"
FILES=$(scripts/ci/changes.sh py-source)
if [ "$FILES" = "__FULL__" ]; then
uvx xenon==0.9.3 \
--max-absolute F \
--max-modules E \
--max-average B \
apps/api apps/voice-agent libs/shared/py \
-e 'apps/api/tests/**' \
-e 'apps/api/scripts/**' \
-e 'libs/shared/py/tests/**'
elif [ -z "$FILES" ]; then
echo "No matching changed files — skipping."
else
uvx xenon==0.9.3 \
--max-absolute F \
--max-modules E \
--max-average B \
$FILES
fi
echo "::endgroup::"
echo "### Complexity (xenon) — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
echo "Complexity: OK (no module exceeds rank D, codebase average ≤ B)"
- name: Docstring coverage on public API surface
id: interrogate
continue-on-error: true
run: |
trap 'echo "::error::repro: uvx interrogate -c pyproject.toml"' ERR
echo "::group::interrogate"
FILES=$(scripts/ci/changes.sh py-source)
if [ "$FILES" = "__FULL__" ]; then
uvx interrogate==1.7.0 -c pyproject.toml apps/api apps/voice-agent libs/shared/py
elif [ -z "$FILES" ]; then
echo "No matching changed files — skipping."
else
uvx interrogate==1.7.0 -c pyproject.toml $FILES
fi
echo "::endgroup::"
echo "### Docstring coverage — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
echo "Docstrings: OK (coverage ≥ 80%)"
# Both security scans are blocking: PRs go red on HIGH/CRITICAL.
# Previously advisory due to pre-existing findings; those must be fixed
# or explicitly ignored per finding.
- name: Bandit security scan (zero-warning policy)
id: bandit
continue-on-error: true
run: |
trap 'echo "::error::repro: uvx bandit -c pyproject.toml"' ERR
echo "::group::bandit (low severity, low confidence — no exclusions)"
FILES=$(scripts/ci/changes.sh py-source)
if [ "$FILES" = "__FULL__" ]; then
uvx --no-build bandit@1.9.4 -c pyproject.toml -r apps/api/app apps/voice-agent/src libs/shared/py --severity-level low --confidence-level low 2>&1 | tee "$RUNNER_TEMP/bandit.out"; _rc=${PIPESTATUS[0]}; python3 scripts/ci/report.py annotations < "$RUNNER_TEMP/bandit.out" || true; test $_rc -eq 0
elif [ -z "$FILES" ]; then
echo "No matching changed files — skipping."
else
uvx --no-build bandit@1.9.4 -c pyproject.toml $FILES --severity-level low --confidence-level low 2>&1 | tee "$RUNNER_TEMP/bandit.out"; _rc=${PIPESTATUS[0]}; python3 scripts/ci/report.py annotations < "$RUNNER_TEMP/bandit.out" || true; test $_rc -eq 0
fi
echo "::endgroup::"
echo "### Bandit — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
echo "Security: OK (0 issues across all severity levels)"
- name: pip-audit (dependency CVEs — blocking)
id: pip-audit
continue-on-error: true
run: |
trap 'echo "::error::repro: uv export + pip-audit"' ERR
echo "::group::pip-audit dependency scan"
# $RUNNER_TEMP, not /tmp: twenty runner instances share the box's /tmp,
# and two python-static jobs from different PRs would audit each
# other's export.
uv export --frozen --no-emit-workspace -o "$RUNNER_TEMP/gaia-requirements.txt"
uvx --no-build pip-audit==2.10.1 --strict --skip-editable --disable-pip --ignore-vuln GHSA-wj6h-64fc-37mp --ignore-vuln PYSEC-2026-1325 -r "$RUNNER_TEMP/gaia-requirements.txt"
echo "::endgroup::"
echo "### pip-audit — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
echo "pip-audit: OK (no CVEs)"
- name: Verdict (every tool above ran; this fails if any of them did not pass)
if: always()
env:
# Step outcomes passed through env to match the quality gate's
# no-inline-expansion convention.
RUFF: ${{ steps.ruff.outcome }}
CUSTOM_LINTS: ${{ steps.custom-lints.outcome }}
IGNORE_WHYS: ${{ steps.ignore-whys.outcome }}
PLR_COMPLEXITY: ${{ steps.plr-complexity.outcome }}
XENON: ${{ steps.xenon.outcome }}
INTERROGATE: ${{ steps.interrogate.outcome }}
BANDIT: ${{ steps.bandit.outcome }}
PIP_AUDIT: ${{ steps.pip-audit.outcome }}
run: |
python3 scripts/ci/report.py step-outcomes \
"ruff=$RUFF" \
"custom-lints=$CUSTOM_LINTS" \
"ignore-whys=$IGNORE_WHYS" \
"plr-complexity=$PLR_COMPLEXITY" \
"xenon=$XENON" \
"interrogate=$INTERROGATE" \
"bandit=$BANDIT" \
"pip-audit=$PIP_AUDIT"
# --- Python mypy --------------------------------------------------------
python-mypy:
name: Python mypy (staged-strict)
needs: [select-runner, changes]
if: needs.changes.outputs.has_python == 'true'
runs-on: ${{ fromJSON(needs.select-runner.outputs.runner) }}
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
clean: ${{ runner.environment != 'self-hosted' }}
fetch-depth: 0
persist-credentials: false
- uses: ./.github/actions/setup-uv
- name: Install api dev + backend dependencies (uv sync)
run: |
echo "::group::uv sync"
uv sync --project apps/api --group backend --group dev
echo "::endgroup::"
echo "deps: OK"
# mypy is single-threaded (measured: ~50s regardless of core count) but
# incremental: with a warm .mypy_cache only changed modules are re-checked.
# The cache is ~50 MB, so the round-trip is cheap even from GitHub's
# side. restore-keys lets a lockfile bump start from the previous cache
# instead of cold.
- name: Restore mypy incremental cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .mypy_cache
key: mypy-${{ runner.os }}-${{ hashFiles('uv.lock') }}-${{ github.sha }}
restore-keys: |
mypy-${{ runner.os }}-${{ hashFiles('uv.lock') }}-
mypy-${{ runner.os }}-
- name: mypy across api + voice-agent + shared + lint tooling (staged-strict + shared.* strict override)
env:
MYPY_CACHE_DIR: ${{ github.workspace }}/.mypy_cache
run: |
echo "::group::mypy"
# mypy needs the whole module graph per project, so it can't be scoped
# to individual changed files.
#
# Still uncovered, deliberately and with the cost measured: apps/api/tests
# (759 errors even under the loosened tests override), apps/api/scripts
# (45) and tools/llm-stub (27, all missing annotations on a dev fixture).
# Named here so the gap stays a decision instead of quietly becoming the
# kind of blind spot that let libs/shared/py drift. On PRs we instead detect which Python
# projects changed (from the .py diff) and check only those project
# roots. libs/shared/py is a dependency of both apps, so any change
# there pulls both apps in. On push we check everything.
FILES=$(scripts/ci/changes.sh files py)
if [ "$FILES" = "__FULL__" ]; then
# --no-build is impossible here, not forgotten. composio pulls pysher, and pysher's
# latest release (1.0.8) publishes no wheel at all, so uv refuses the install:
# "Wheels are required for `pysher` because building from source is disabled".
# No pin of ours can change that — it is upstream's to publish. --frozen already
# locks every resolved version from uv.lock, which is the part we do control.
# Sonar githubactions:S8541 therefore stays open here; NOSONAR is not honoured by
# that analyzer, so it is resolved as Accepted in the SonarCloud UI instead.
uv run --project apps/api --frozen --group backend --group dev mypy apps/api/app apps/voice-agent/src libs/shared/py tools/lints --ignore-missing-imports
elif [ -z "$FILES" ]; then
echo "No changed Python files — skipping."
else
TARGETS=""
if echo "$FILES" | grep -q '^libs/shared/py/'; then
TARGETS="apps/api/app apps/voice-agent/src libs/shared/py"
else
echo "$FILES" | grep -q '^apps/api/' && TARGETS="$TARGETS apps/api/app"
echo "$FILES" | grep -q '^apps/voice-agent/' && TARGETS="$TARGETS apps/voice-agent/src"
fi
echo "$FILES" | grep -q '^tools/lints/' && TARGETS="$TARGETS tools/lints"
if [ -z "$TARGETS" ]; then
echo "No tracked Python project changed — skipping."
else
# --no-build is impossible here, not forgotten. composio pulls pysher, and pysher's
# latest release (1.0.8) publishes no wheel at all, so uv refuses the install:
# "Wheels are required for `pysher` because building from source is disabled".
# No pin of ours can change that — it is upstream's to publish. --frozen already
# locks every resolved version from uv.lock, which is the part we do control.
# Sonar githubactions:S8541 therefore stays open here; NOSONAR is not honoured by
# that analyzer, so it is resolved as Accepted in the SonarCloud UI instead.
uv run --project apps/api --frozen --group backend --group dev mypy $TARGETS --ignore-missing-imports
fi
fi
echo "::endgroup::"
echo "mypy: OK (no type errors)"
# --- Observability score (evlog map) ------------------------------------
# Python port of evlog's `map` (tools/evlog_map): scores every FastAPI/ARQ
# entry point on wide-event instrumentation, weighted so money/auth routes
# count double. Full-repo score goes to the job summary; on PRs the changed
# files are scored at the merge-base and at HEAD with the same scanner and
# the lane fails on regression (new files get a hard "good"-grade floor).
# The scanners themselves are stdlib-only; uv is installed only to run their
# test suites (first step).
observability:
name: Observability score (evlog map)
needs: [select-runner, changes, python-static, python-mypy, dead-code]
if: ${{ !cancelled() && (needs.changes.outputs.has_python == 'true' || needs.changes.outputs.has_typescript == 'true') }}
runs-on: ${{ fromJSON(needs.select-runner.outputs.runner) }}
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
clean: ${{ runner.environment != 'self-hosted' }}
fetch-depth: 0
persist-credentials: false
# Node toolchain only for the bots scanner (TS compiler API needs
# node_modules); the Python scanner stays stdlib-only.
- uses: ./.github/actions/setup-node-pnpm
- uses: ./.github/actions/setup-uv
# This lane's verdict is only worth what its rules are worth, and those
# rules have been silently wrong (a discarded-error except matrix, route
# resolution that found 2 of 283 paths). Both still printed a score — so
# the tools are tested BEFORE they gate: tools/evlog_map (static) and
# tools/logcheck (black-box) run in ~1s, deliberately un-grouped so a
# failure traceback is the signal.
- name: Scanner self-tests
run: |
set -euo pipefail
trap 'echo "::error::repro: uvx pytest tools/evlog_map tools/logcheck -q"' ERR
uvx --no-build pytest@9.1.1 tools/evlog_map tools/logcheck -q
echo "scanner self-tests: OK (evlog map rules + log-contract invariants)"
# --min-score 100 gates BOTH quality and discovery: an empty map scores
# 0 (not 100), so a scanner that stops seeing entry points fails the
# gate instead of reporting a perfect score over nothing — no
# hand-maintained entry-count floor needed.
- name: Full observability score — API + voice-agent + bots (details in job summary)
run: |
set -euo pipefail
trap 'echo "::error::repro: python3 tools/evlog_map --min-score 100 / node scripts/ci/checks.mjs evlog-map-bots --min-score 100"' ERR
echo "evlog map — python surface (apps/api + apps/voice-agent)"
python3 tools/evlog_map --github-summary --no-write --min-score 100
echo "evlog map — bots surface (apps/bots + libs/shared/ts)"
node scripts/ci/checks.mjs evlog-map-bots --min-score 100
echo "observability: full scores above; per-entry detail in the job summary only"
echo "### Observability — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
- name: Changed-files regression gate (per-file, score must not drop)
if: github.event_name == 'pull_request'
run: |
set -euo pipefail
FILES=$(scripts/ci/changes.sh files py)
if [ -z "$FILES" ]; then
echo "observability: OK (no changed Python files)"
exit 0
fi
if [ "$FILES" = "__FULL__" ]; then
echo "observability: OK (full run — covered by the full scan above)"
exit 0
fi
echo "::group::evlog map — changed files vs merge-base (per-file ratchet)"
scripts/ci/audit.sh evlog
echo "::endgroup::"
echo "observability: OK (no per-file regression on changed Python files)"
- name: Bots regression gate (full surface, held at 100)
if: github.event_name == 'pull_request'
run: |
set -euo pipefail
BOT_FILES=$(scripts/ci/changes.sh files ts)
if [ -z "$BOT_FILES" ]; then
echo "observability-bots: OK (no changed TypeScript files)"
exit 0
fi
if [ "$BOT_FILES" != "__FULL__" ] && ! grep -qE '^(apps/bots/|libs/shared/ts/)' <<< "$BOT_FILES"; then
echo "observability-bots: OK (no bot-surface files changed)"
exit 0
fi
# The bots surface is small and already at 100 — hold it there
# outright rather than baselining per file. An empty map scores 0
# (not 100), so --min-score also catches the scanner going blind.
echo "::group::evlog map — bots surface gate"
node scripts/ci/checks.mjs evlog-map-bots --min-score 100
echo "::endgroup::"
echo "observability-bots: OK (surface held at 100)"
# --- Wide-event cross-runtime conformance --------------------------------
# The Python services and the TypeScript bots must emit the SAME log shape,
# so one LogQL query spans both. This lane runs both logging stacks for real,
# captures the NDJSON each prints, and diffs the shapes against each other and
# against scripts/ci/wide-event-conformance/contract.json. A field renamed,
# retyped, or added on only one side fails here — which is the only thing that
# actually stops the two halves drifting apart between reviews.
wide-event-conformance:
name: Wide events have one shape across Python and TypeScript
needs: [select-runner, changes, python-static, python-mypy, dead-code]
if: ${{ !cancelled() && (needs.changes.outputs.has_python == 'true' || needs.changes.outputs.has_typescript == 'true') }}
runs-on: ${{ fromJSON(needs.select-runner.outputs.runner) }}
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
clean: ${{ runner.environment != 'self-hosted' }}
fetch-depth: ${{ runner.environment == 'self-hosted' && '0' || '1' }}
persist-credentials: false
- uses: ./.github/actions/setup-node-pnpm
- uses: ./.github/actions/setup-uv
- name: Emit real events from both runtimes and diff their shapes
run: |
trap 'echo "::error::repro: python3 scripts/ci/wide-event-conformance/run.py --show-capture"' ERR
echo "::group::wide-event conformance"
python3 scripts/ci/wide-event-conformance/run.py --show-capture
echo "::endgroup::"
echo "### Wide-event conformance — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
echo "wide events: OK (Python and TypeScript emit one shape)"
# --- Dead code detection (knip + vulture) — enforced ---------------------
# Runs in --strict mode: any knip (TS) or vulture (Python) finding fails the
# lane. The scan is tightened to drop systematic false positives (tests,
# Pydantic/dynamic-dispatch, framework-consumed files) — see config/knip.config.ts
# and the [tool.vulture] ignore_names / ignore_decorators lists in pyproject.toml.
dead-code:
name: Dead code (knip + vulture, strict)
needs: [select-runner, changes]
if: needs.changes.outputs.has_typescript == 'true' || needs.changes.outputs.has_python == 'true'
runs-on: ${{ fromJSON(needs.select-runner.outputs.runner) }}
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
clean: ${{ runner.environment != 'self-hosted' }}
fetch-depth: ${{ runner.environment == 'self-hosted' && '0' || '1' }}
persist-credentials: false
- uses: ./.github/actions/setup-node-pnpm
- uses: ./.github/actions/setup-uv
- name: Install vulture (Python dead-code detector)
run: |
echo "::group::uv tool install vulture"
uv tool install vulture
echo "::endgroup::"
- name: knip (TypeScript) + vulture (Python) dead-code scan (strict)
run: |
trap 'echo "::error::repro: bash scripts/dead-code-check.sh --strict"' ERR
echo "::group::dead-code scan"
bash scripts/dead-code-check.sh --strict --verbose
echo "::endgroup::"
echo "### Dead code — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
echo "Dead code: OK (no unused TS or Python code)"
# --- Grafana alert rules (pint + promtool) ------------------------------
# Grafana's alert rules are managed objects, not Prometheus rule files, so they
# cannot be fed to promtool directly. The extractor derives a Prometheus-native
# rule file from the single source of truth (no second copy to maintain) and
# the lane proves three things:
#
# 1. every rule matches the documented A/B/C shape AND has a promtool test
# file — the extractor aborts on either violation (--test-dir),
# 2. the PromQL is valid and sane — pint `--offline` (promql/syntax,
# promql/impossible, promql/fragile, alerts/comparison, alerts/for, …),
# 3. every rule actually fires under its trigger fixture and stays quiet
# under its quiet fixture — promtool test rules, which also honours each
# rule's real `for` duration.
#
# The two things CI structurally cannot prove are that the metric exists with
# matching labels (promql/series) and that the threshold is reachable
# (alerts/count) — both need a live server and are a documented local
# Prometheus pass — see infra/docker/observability/CLAUDE.md.
alert-rules:
name: Grafana alert rules (pint + promtool)
needs: [select-runner, changes, python-static, python-mypy, dead-code]
if: ${{ !cancelled() && (needs.changes.outputs.has_alert_rules == 'true') }}
runs-on: ${{ fromJSON(needs.select-runner.outputs.runner) }}
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
clean: ${{ runner.environment != 'self-hosted' }}
fetch-depth: ${{ runner.environment == 'self-hosted' && '0' || '1' }}
persist-credentials: false
- uses: ./.github/actions/setup-uv
- name: Install pint + promtool (pinned release binaries, same versions prod runs)
run: bash scripts/ci/audit.sh alert-rule-tools
- name: Extract rules and enforce test coverage (no silent skips)
run: |
trap 'echo "::error::repro: uv run --no-build tools/alert-rules/extract_promql.py"' ERR
echo "::group::extract"
# --no-build forbids building sdist deps from source; the pinned
# pyyaml wheel is all this script needs.
uv run --no-build tools/alert-rules/extract_promql.py -o tools/alert-rules/gaia-rules.yaml \
--test-dir tools/alert-rules/tests
echo "::endgroup::"
echo "### Alert rules (extract) — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
echo "Extraction: OK (every rule matched the A/B/C shape and has a test)"
- name: pint lint (PromQL validity, impossible queries, fragile alerts)
run: |
trap 'echo "::error::repro: pint --offline lint tools/alert-rules/gaia-rules.yaml"' ERR
echo "::group::pint lint --offline"
pint --no-color --offline --config config/pint.hcl lint tools/alert-rules/gaia-rules.yaml
echo "::endgroup::"
echo "### Alert rules (pint) — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
echo "pint: OK (offline checks pass — promql/series still needs a run against a local Prometheus)"
- name: promtool check rules (independent rule-file validation)
run: |
trap 'echo "::error::repro: promtool check rules tools/alert-rules/gaia-rules.yaml"' ERR
echo "::group::promtool check rules"
promtool check rules tools/alert-rules/gaia-rules.yaml
echo "::endgroup::"
echo "### Alert rules (promtool check) — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
echo "promtool: OK (rule file is valid)"
- name: promtool test rules (every rule fires and stays quiet)
run: |
trap 'echo "::error::repro: promtool test rules tools/alert-rules/tests/*.yaml"' ERR
echo "::group::promtool test rules"
promtool test rules tools/alert-rules/tests/*.yaml
echo "::endgroup::"
echo "### Alert rules (promtool test) — ✅ passed" >> "$GITHUB_STEP_SUMMARY"
echo "promtool: OK (all per-rule firing + quiet tests pass)"
# --- Suppression hygiene -------------------------------------------------
# Baseline-free governance: every inline suppression (# noqa / # type: ignore
# / biome-ignore) must carry a same-line why; every ruff/mypy escape hatch in
# pyproject.toml must still mask a live finding; and hooks must pin the same
# tool versions CI runs. No checked-in baseline file to rot or game — the
# checks are working-tree scans, reproducible locally with the commands below.
suppression-hygiene:
name: "Suppression hygiene (every # noqa / # type: ignore / biome-ignore has a why)"
needs: [select-runner, changes, python-static, python-mypy, dead-code]
if: ${{ !cancelled() }}
# No job-level language gate on purpose: check_tool_pins reads the WORKFLOW
# FILES themselves (code-quality.yml, main.yml), so a workflow-only PR is
# exactly the change this lane validates — and nx-affected correctly marks
# no language project affected for such PRs. The steps self-scope via