-
Notifications
You must be signed in to change notification settings - Fork 0
1034 lines (916 loc) · 40.4 KB
/
Copy pathci.yml
File metadata and controls
1034 lines (916 loc) · 40.4 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
# The Stem CI Pipeline
# Mustard Seed Networks - Network Performance Testing
#
# Runs on: push to main, pull requests to main
# Jobs: backend, frontend, c-lint, security, quality, build
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Least-privilege default: every job gets read-only contents unless it
# specifically needs more (granted at the job level below). None of these
# jobs comment on or annotate PRs via the GitHub API, so pull-requests/checks
# write were unused broad grants — only the `security` job's SARIF upload
# needs security-events:write (+ actions:read, required for private repos
# per github/codeql-action/upload-sarif's docs; niac hit this exact gap).
permissions:
contents: read
jobs:
# ============================================================================
# Path filter — classifies which areas changed so downstream jobs can skip
# ============================================================================
# Adds ~5s to every run; saves several minutes when a PR only touches one
# area (docs-only / c-only / frontend-only). The CI Complete aggregator at
# the bottom of this file enforces that all required jobs either passed or
# were intentionally skipped, so branch protection can require just CI
# Complete instead of every individual job.
changes:
name: Changed paths
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
c: ${{ steps.filter.outputs.c }}
docs: ${{ steps.filter.outputs.docs }}
workflows: ${{ steps.filter.outputs.workflows }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter
with:
filters: |
backend:
- '**/*.go'
- 'go.mod'
- 'go.sum'
- 'Makefile'
- 'mk/**'
frontend:
- 'ui/**'
c:
- 'src/**'
- 'include/**'
- 'tests/c/**'
- '.clang-tidy'
- '.clang-format'
docs:
- '**/*.md'
workflows:
- '.github/workflows/**'
# ============================================================================
# Backend (Go)
# ============================================================================
backend:
name: Backend (Go)
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.backend == 'true' || needs.changes.outputs.frontend == 'true' || needs.changes.outputs.c == 'true' || needs.changes.outputs.workflows == 'true'
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Set up Go
uses: ./.github/actions/setup-go
- name: Set up Node.js
uses: ./.github/actions/setup-node
- name: Download dependencies
run: go mod download
- name: Verify dependencies
run: go mod verify
- name: Build embedded UI assets
run: |
npm ci --prefix ui
npm run build --prefix ui
- name: Install native build dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
clang-18 \
libbpf-dev \
libelf-dev \
libxdp-dev \
make \
zlib1g-dev
- name: Build native dataplane library
run: make CC=clang-18 dataplane
- name: Compile native Go bindings
run: CGO_ENABLED=1 go test -run '^$' ./internal/reflector/dataplane ./internal/services/orchestrator/dataplane
- name: Run golangci-lint
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1
with:
version: v2.12.2
args: --timeout=5m --new-from-rev=HEAD~1
- name: Verify API schemas are up to date
run: ./scripts/check-schema-drift.sh
- name: Verify generated TS types are up to date
run: ./scripts/check-types-drift.sh
- name: Output-escaping / XSS gate (#343)
run: ./scripts/check-output-escaping.sh
- name: Route-policy gate (capability registry)
run: ./scripts/check-route-policy.sh
- name: JSON wire-casing gate — camelCase ratchet
run: ./scripts/check-json-casing.sh
- name: Filename-policy gate — no monolith naming outside internal/api
run: ./scripts/check-filename-policy.sh
- name: File-size gate (STRICT — W5.5 bookend, fleet-wide Go + TS red flags)
run: STRICT=1 ./scripts/check-file-size.sh
- name: Run tests
run: CGO_ENABLED=0 go test -v -coverprofile=coverage.out ./...
- name: Check coverage threshold
run: |
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
echo "Current coverage: ${COVERAGE}%"
# Minimum coverage threshold. Acts as an anti-regression floor.
# Overall coverage is ~58% (platform-specific code in Linux
# dataplane and TUI pulls the average down; critical packages —
# auth, database, logging, modules, config — are 90%+).
#
# Ratchet plan (raise after each release exceeds the next tier
# by 5+ points):
# 2026-Q2: 50 (current)
# 2026-Q3: 55
# 2026-Q4: 60
# 2027-Q2: 65
# long-term: 70
MIN_COVERAGE=50
if (( $(echo "$COVERAGE < $MIN_COVERAGE" | bc -l) )); then
echo "ERROR: Coverage ${COVERAGE}% is below minimum threshold of ${MIN_COVERAGE}%"
exit 1
fi
- name: Upload coverage
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
file: ./coverage.out
flags: backend
fail_ci_if_error: false
# ============================================================================
# Go Race Detector
# ============================================================================
# Dedicated race-only job to match seed's CI matrix (#56). Stem's main
# backend tests run under CGO_ENABLED=0; the race detector requires cgo,
# which means the dataplane cgo bindings need libxdp/libbpf/libelf at link
# time. Install the same native deps as the Backend (Go) job and build
# libreflector.a before invoking go test.
race:
name: Backend (race detector)
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.backend == 'true' || needs.changes.outputs.frontend == 'true' || needs.changes.outputs.c == 'true' || needs.changes.outputs.workflows == 'true'
env:
GOMODCACHE: ${{ github.workspace }}/.cache/go/pkg/mod
GOCACHE: ${{ github.workspace }}/.cache/go/build
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: ./.github/actions/setup-go
- name: Set up Node.js
uses: ./.github/actions/setup-node
- name: Build embedded UI assets
run: |
npm ci --prefix ui
npm run build --prefix ui
- name: Install native build dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
clang-18 \
libbpf-dev \
libelf-dev \
libxdp-dev \
make \
zlib1g-dev
- name: Build native dataplane library
run: make CC=clang-18 dataplane
- name: Run tests with race detector
env:
CGO_ENABLED: '1'
# Go 1.26 enables the Green Tea GC by default. It crashes
# intermittently under `-race` + CGO (the dataplane bindings) with
# a fatal error inside mgcmark_greenteagc.go — a toolchain bug, not
# an application data race (no "DATA RACE" report). Pin the prior
# GC for this job so race results are deterministic. Remove once
# the upstream greentea+race+cgo crash is fixed.
GOEXPERIMENT: nogreenteagc
run: go test -short -race -timeout=20m ./...
# ============================================================================
# Frontend (TypeScript)
# ============================================================================
frontend:
name: Frontend (TypeScript)
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.workflows == 'true'
defaults:
run:
working-directory: ui
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Node.js
uses: ./.github/actions/setup-node
- name: Install dependencies
run: npm ci
- name: Run Biome lint
run: npx @biomejs/biome check src/
- name: Design token discipline (Phase 0 gate)
run: ../scripts/check-token-discipline.sh
- name: Type check
run: npm run typecheck
- name: Build
run: npm run build
- name: Upload frontend build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-dist
path: internal/api/ui/
retention-days: 1
- name: Build Storybook
run: npm run build-storybook -- --output-dir storybook-static --quiet
working-directory: ui
- name: Upload Storybook static
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: storybook-static
path: ui/storybook-static/
retention-days: 7
# ============================================================================
# C Lint (C23 Standard - NOT C++)
# ============================================================================
c-lint:
name: C Lint (C23)
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.c == 'true' || needs.changes.outputs.workflows == 'true'
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install C23 tools (clang-tidy, cppcheck, bear)
run: |
sudo apt-get update
sudo apt-get install -y \
clang-18 \
clang-tidy-18 \
clang-format-18 \
cppcheck \
bear \
gcc-13
# Set clang-18 as default
sudo update-alternatives --install /usr/bin/clang clang /usr/bin/clang-18 100
sudo update-alternatives --install /usr/bin/clang-tidy clang-tidy /usr/bin/clang-tidy-18 100
sudo update-alternatives --install /usr/bin/clang-format clang-format /usr/bin/clang-format-18 100
# Verify versions
clang --version
clang-tidy --version
gcc-13 --version
- name: Check if C files exist
id: check-c
run: |
if [ -d "src" ] && find src include tests/c -name '*.c' -o -name '*.h' 2>/dev/null | grep -q .; then
echo "has_c=true" >> $GITHUB_OUTPUT
echo "Found C source files"
else
echo "has_c=false" >> $GITHUB_OUTPUT
echo "No C source files found, skipping C lint"
fi
- name: Generate compile_commands.json
if: steps.check-c.outputs.has_c == 'true'
run: |
# Create compile_commands.json for clang-tidy
cat > compile_commands.json << 'EOF'
[
{
"directory": "${{ github.workspace }}",
"command": "gcc-13 -std=c23 -Wall -Wextra -Wpedantic -Iinclude -c src/dataplane/common/core.c",
"file": "src/dataplane/common/core.c"
}
]
EOF
# Use bear to generate proper compile_commands.json if Makefile exists
if [ -f "Makefile" ]; then
# Update CC to use gcc-13 for C23 support
export CC=gcc-13
# Generate compile database
bear -- make dataplane || true
fi
- name: Run clang-format check (C23)
if: steps.check-c.outputs.has_c == 'true'
continue-on-error: true
run: |
echo "Checking C code formatting (C23 standard)..."
find src include tests/c -name '*.c' -o -name '*.h' 2>/dev/null | \
xargs clang-format --dry-run --Werror --style=file
- name: Run clang-tidy (C23)
if: steps.check-c.outputs.has_c == 'true'
continue-on-error: true
run: |
echo "Running clang-tidy with C23 checks..."
find src include tests/c -name '*.c' -o -name '*.h' 2>/dev/null | \
xargs clang-tidy \
--config-file=.clang-tidy \
--extra-arg=-std=c23 \
--extra-arg=-Iinclude \
--warnings-as-errors='*'
- name: Run cppcheck (C only, not C++)
if: steps.check-c.outputs.has_c == 'true'
continue-on-error: true
run: |
echo "Running cppcheck for C code..."
cppcheck \
--std=c23 \
--language=c \
--enable=warning,style,performance,portability \
--suppress=missingIncludeSystem \
--suppress=unusedFunction \
--inline-suppr \
--error-exitcode=1 \
--template='{file}:{line}: {severity}: {message} [{id}]' \
-I include \
src/ 2>&1 | tee cppcheck.log
if [ -s cppcheck.log ]; then
echo "::warning::cppcheck found issues"
fi
# ============================================================================
# Dataplane Safety — BLOCKING memory-safety gate for the C packet parser
# ============================================================================
# The dataplane parser is the one place attacker-controlled bytes meet C.
# Unlike the advisory c-lint job above, this gate FAILS the build on:
# - any ASAN/UBSan finding in the parser unit tests,
# - a cppcheck warning-class issue in packet.c (scoped: tree-wide style
# findings stay advisory in c-lint),
# - any crash discovered by a bounded libFuzzer run of the parser.
dataplane-safety:
name: Dataplane Safety (ASAN + fuzz)
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.c == 'true' || needs.changes.outputs.workflows == 'true'
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install clang-18 + libFuzzer + cppcheck
run: |
sudo apt-get update
sudo apt-get install -y clang-18 cppcheck
# compiler-rt provides the libFuzzer + sanitizer runtimes.
sudo apt-get install -y libclang-rt-18-dev || true
sudo update-alternatives --install /usr/bin/clang clang /usr/bin/clang-18 100
clang --version
- name: Packet-parser tests under ASAN/UBSan (blocking)
run: make c-test-asan CC=clang
- name: cppcheck gate on the parser (blocking, warning-class)
run: |
cppcheck \
--std=c23 --language=c \
--enable=warning,performance,portability \
--suppress=missingIncludeSystem \
--suppress=unusedFunction \
--suppress=preprocessorErrorDirective \
--inline-suppr \
--error-exitcode=1 \
--template='{file}:{line}: {severity}: {message} [{id}]' \
-I include \
src/dataplane/common/packet.c
- name: Fuzz the packet parser (blocking on crash)
run: make c-fuzz FUZZ_CC=clang FUZZ_SECONDS=120
# ============================================================================
# Security Scanning
# ============================================================================
security:
name: Security Scanning
runs-on: ubuntu-latest
# Always runs — security checks are non-negotiable on every PR.
permissions:
contents: read
security-events: write # upload-sarif (gosec + Trivy)
actions: read # required alongside security-events:write on private repos
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Set up Go
uses: ./.github/actions/setup-go
- name: Run govulncheck
run: |
go install golang.org/x/vuln/cmd/govulncheck@v1.3.0
govulncheck ./...
- name: Run gosec
uses: securego/gosec@9e6a9843d7a4a6e3e9a8539b02612c8a4aa3f889 # v2.27.1
continue-on-error: true
with:
args: "-fmt sarif -out gosec-results.sarif ./..."
- name: Upload gosec results
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
if: always() && hashFiles('gosec-results.sarif') != ''
continue-on-error: true
with:
sarif_file: gosec-results.sarif
- name: Set up Node.js
uses: ./.github/actions/setup-node
- name: Run npm audit
working-directory: ui
run: |
npm ci
npm audit --audit-level=high
# gitleaks-action requires a paid licence for organization repos. Use the
# MIT-licensed gitleaks CLI directly instead — same engine, same config.
# Scans full history (the security job checks out with fetch-depth: 0).
- name: Run gitleaks
env:
GITLEAKS_VERSION: "8.30.0"
# SHA-256 of gitleaks_8.30.0_linux_x64.tar.gz from the upstream
# checksums.txt. Rotate this together with GITLEAKS_VERSION on upgrade.
GITLEAKS_SHA256: "79a3ab579b53f71efd634f3aaf7e04a0fa0cf206b7ed434638d1547a2470a66e"
run: |
set -euo pipefail
curl -sSfL -o gitleaks.tar.gz \
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
echo "${GITLEAKS_SHA256} gitleaks.tar.gz" | sha256sum -c -
tar -xzf gitleaks.tar.gz gitleaks
./gitleaks git . --config .gitleaks.toml --redact --no-banner --exit-code 1
- name: Run Trivy vulnerability scanner
# exit-code is 0 here so the SARIF always gets written; we still
# surface findings as a separate step that fails CI when the SARIF
# contains CRITICAL/HIGH entries.
id: trivy
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '0'
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
if: always() && hashFiles('trivy-results.sarif') != ''
continue-on-error: true
with:
sarif_file: trivy-results.sarif
- name: Fail on CRITICAL/HIGH findings
# Reads the SARIF Trivy just produced and fails CI if any CRITICAL or
# HIGH-severity result is present. We do this in a separate step so
# the SARIF is always uploaded for security tab visibility.
if: always() && hashFiles('trivy-results.sarif') != ''
run: |
if jq -e '.runs[].results[] | select(.level=="error" or .properties.security_severity == "high" or .properties.security_severity == "critical")' trivy-results.sarif >/dev/null; then
echo "::error::Trivy found CRITICAL or HIGH severity issues — see Security tab."
jq '.runs[].results[] | {ruleId, level, severity: .properties.security_severity, message: .message.text}' trivy-results.sarif
exit 1
fi
# ===========================================================================
# Semgrep SAST — required gate (Phase 4, replaces CodeQL as the required
# SAST signal since CodeQL is skipped entirely on private repos)
# ===========================================================================
# Blocking on ERROR severity only for now — a deliberate first step,
# mirroring seed #1737. WARNING/MEDIUM findings are visible in the job log
# but do not fail the build yet; ratchet to a stricter --severity once the
# current WARNING backlog is triaged in a follow-up. One known false
# positive is already annotated inline with `// nosemgrep: <rule-id> --
# <reason>` (mirroring the existing `#nosec` justifications) so the
# ratchet won't need to re-litigate it: internal/auth/hibp.go (SHA-1 is
# the HIBP k-anonymity API contract).
#
# Runs unconditionally (no path filter) — same rationale as `security`
# above: SAST coverage on every PR is non-negotiable, and Semgrep is fast
# enough (~1-2 min for this ruleset) that gating it behind path filters
# isn't worth the added complexity.
# Defined once in MustardSeedNetworks/.github (fleet-shared); ci-complete
# still needs: [semgrep]. Edit the pinned version / config policy there.
semgrep:
uses: MustardSeedNetworks/.github/.github/workflows/semgrep.yml@762b756e6fb5803443b9bcfdba03b88596c18e04 # v1.0.0
permissions:
contents: read
# ============================================================================
# Quality
# ============================================================================
quality:
name: Quality Checks
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.backend == 'true' || needs.changes.outputs.frontend == 'true' || needs.changes.outputs.c == 'true' || needs.changes.outputs.workflows == 'true'
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Check for sensitive files
run: |
SENSITIVE_FILES=$(find . -type f \( \
-name "*.env" -o \
-name "*.pem" -o \
-name "*.key" -o \
-name "*secret*" -o \
-name "*credential*" \
\) ! -path "./.git/*" ! -name "*.example" ! -name "*.sample" 2>/dev/null || true)
if [ -n "$SENSITIVE_FILES" ]; then
echo "Sensitive files found:"
echo "$SENSITIVE_FILES"
exit 1
fi
echo "No sensitive files found"
- name: Check for TODO/FIXME in critical paths
run: |
if grep -rn "TODO\|FIXME" --include="*.go" internal/ cmd/ 2>/dev/null | head -20; then
echo "Note: TODOs found in Go code (info only)"
fi
- name: Check for unsafe logging patterns
run: |
echo "Checking for dangerous logging patterns..."
DANGEROUS_PATTERNS=(
'log\.Print.*password'
'log\.Print.*token'
'log\.Print.*Authorization'
'log\.Print.*apikey'
'log\.Print.*secret'
'fmt\.Printf.*password'
'fmt\.Printf.*token'
)
VIOLATIONS=0
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
if grep -rn -E "$pattern" --include="*.go" --exclude="*_test.go" internal/ cmd/ 2>/dev/null; then
echo "❌ Found dangerous logging pattern: $pattern"
VIOLATIONS=$((VIOLATIONS + 1))
fi
done
if [ $VIOLATIONS -gt 0 ]; then
echo ""
echo "ERROR: Found $VIOLATIONS unsafe logging pattern(s)"
echo "Avoid logging sensitive data like passwords, tokens, or secrets"
exit 1
fi
echo "✅ No unsafe logging patterns detected"
# ============================================================================
# Internationalization (i18n) Validation
# ============================================================================
i18n:
name: i18n Validation
runs-on: ubuntu-latest
needs: changes
if: |
needs.changes.outputs.frontend == 'true'
|| needs.changes.outputs.workflows == 'true'
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Validate i18n (parity, glossary, banned vocab, etc.)
# Previously a placeholder that printed "✅ i18n validation complete"
# without checking anything. Replaced with the canonical shared script
# in scripts/i18n/ — identical to seed and niac.
#
# --ratchet demotes the pre-existing-debt checks (no-fallback-patterns
# and locked-versions) from fail to warn. Drop --ratchet once Phase 3
# cleanup brings both counts to zero across seed/stem/niac.
# See scripts/i18n/README.md and
# msn-docs-internal/05-Engineering/I18N_CONVENTIONS.md.
run: ./scripts/i18n/validate.sh --ratchet
# ============================================================================
# Documentation Checks
# ============================================================================
# Markdown lint + link check. Mirrors seed's docs job (#56). Both substeps
# are advisory (continue-on-error) — they surface issues without blocking
# the build, matching seed's stance that doc quality is a ratcheting goal.
docs:
name: Documentation Quality
runs-on: ubuntu-latest
# Always runs — cheap (runs in seconds) and applies to every PR.
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Check markdown links
uses: gaurav-nelson/github-action-markdown-link-check@5c5dfc0ac2e225883c0e5f03a85311ec2830d368 # v1
continue-on-error: true
with:
use-quiet-mode: "yes"
- name: Lint markdown files
uses: DavidAnson/markdownlint-cli2-action@8de2aa07cae85fd17c0b35642db70cf5495f1d25 # v24.0.0
continue-on-error: true
with:
globs: |
**/*.md
!**/node_modules/**
!**/dist/**
# ============================================================================
# Build
# ============================================================================
build:
name: Build (${{ matrix.os }}-${{ matrix.arch }})
needs: [changes, backend, frontend]
runs-on: ${{ matrix.runner }}
# always() lets the if: evaluate when backend was skipped upstream.
# Note: matrix.required draft-skip was attempted here originally but
# GitHub Actions rejects `matrix` in job-level if: (the matrix context
# isn't bound until after the strategy expands). All matrix rows run
# on every PR; per-arch arches are fast enough that the savings weren't
# worth a workaround.
#
# macOS runners are 10x the minute multiplier of ubuntu-latest and are
# NOT built here on PRs — darwin/amd64 (Intel) is dropped fleet-wide,
# and darwin/arm64's real release binary is produced by goreleaser in
# release.yml (unchanged). Since stem is CGO_ENABLED=0 (pure Go), the
# darwin/arm64 build cross-compiles trivially on ubuntu; the dedicated
# darwin-compile-check job below verifies it still compiles without
# paying for a macOS runner on every PR.
if: |
always() &&
needs.frontend.result == 'success' &&
(needs.backend.result == 'success' || needs.backend.result == 'skipped')
strategy:
fail-fast: false
matrix:
include:
- os: linux
arch: amd64
runner: ubuntu-latest
goos: linux
goarch: amd64
required: true
- os: linux
arch: arm64
runner: ubuntu-latest
goos: linux
goarch: arm64
required: false
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: ./.github/actions/setup-go
- name: Download frontend build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: frontend-dist
path: internal/api/ui/
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: '0'
run: |
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# UI build hash matches the Makefile recipe (md5 of all embedded UI files).
# Mandatory per CLAUDE.md Universal Build Contract — /__version must
# return a non-empty uiBuildHash to prove the UI was embedded.
UI_BUILD_HASH=$(find internal/api/ui -type f -exec md5sum {} \; 2>/dev/null | sort | md5sum 2>/dev/null | cut -d' ' -f1)
echo "UI_BUILD_HASH=${UI_BUILD_HASH}"
go build -trimpath -buildvcs=false \
-ldflags "-s -w \
-X github.com/MustardSeedNetworks/stem/internal/version.Version=${VERSION} \
-X github.com/MustardSeedNetworks/stem/internal/version.Commit=${COMMIT} \
-X github.com/MustardSeedNetworks/stem/internal/version.BuildTime=${BUILD_TIME} \
-X github.com/MustardSeedNetworks/stem/internal/version.UIBuildHash=${UI_BUILD_HASH}" \
-o bin/stem-${{ matrix.os }}-${{ matrix.arch }} \
./cmd/stem/
- name: Verify binary
if: matrix.os == 'linux' && matrix.arch == 'amd64' || matrix.os == 'darwin'
run: |
./bin/stem-${{ matrix.os }}-${{ matrix.arch }} version || true
- name: Verify UIBuildHash embedded (Universal Build Contract)
# Static check: binary must contain the md5 hash of the embedded
# UI, proving the -X ...UIBuildHash=... ldflag was set in
# `Build binary`. Failure means /__version would return
# uiBuildHash="unknown" in prod — silent contract violation.
# Only runs for native linux/amd64 + darwin (where `strings`
# is available); cross-compiled arm64 binary uses identical
# build command so it's covered by the amd64 check.
if: matrix.os == 'linux' && matrix.arch == 'amd64'
run: |
if [ -z "$(ls -A internal/api/ui/ 2>/dev/null | grep -v '^\.gitkeep$')" ]; then
echo "::error::internal/api/ui is empty — frontend-dist artifact missing?"
exit 1
fi
EXPECTED=$(find internal/api/ui -type f -exec md5sum {} \; | sort | md5sum | cut -d' ' -f1)
if [ "${#EXPECTED}" -ne 32 ]; then
echo "::error::Computed UI_BUILD_HASH is not 32 chars: '$EXPECTED'"
exit 1
fi
echo "Expected UIBuildHash: $EXPECTED"
if strings ./bin/stem-${{ matrix.os }}-${{ matrix.arch }} | grep -qF "$EXPECTED"; then
echo " ✓ UIBuildHash embedded correctly"
else
echo "::error::Binary does not contain UIBuildHash $EXPECTED"
echo " → Universal Build Contract violated. Check -X ...UIBuildHash=... ldflag in 'Build binary' step."
exit 1
fi
- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: stem-${{ matrix.os }}-${{ matrix.arch }}
path: bin/stem-${{ matrix.os }}-${{ matrix.arch }}
retention-days: 7
# ============================================================================
# Darwin compile check — verifies the release target still cross-compiles
# ============================================================================
# macOS runners cost 10x the ubuntu-latest minute multiplier, so darwin
# binaries are no longer built on PRs at all (see the `build` job comment
# above). stem is CGO_ENABLED=0 (pure Go), so GOOS=darwin GOARCH=arm64
# cross-compiles cleanly on ubuntu-latest — this job is a cheap compile-only
# smoke check that the darwin/arm64 target hasn't broken, without paying for
# a macOS runner. The actual signed darwin/arm64 release binary is still
# produced by goreleaser in release.yml, unchanged. darwin/amd64 (Intel) is
# dropped fleet-wide and is not checked here.
darwin-compile-check:
name: Darwin compile check (arm64)
runs-on: ubuntu-latest
needs: [changes, backend, frontend]
if: |
always() &&
needs.frontend.result == 'success' &&
(needs.backend.result == 'success' || needs.backend.result == 'skipped')
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: ./.github/actions/setup-go
- name: Download frontend build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: frontend-dist
path: internal/api/ui/
- name: Cross-compile for darwin/arm64 (no macOS runner needed)
env:
GOOS: darwin
GOARCH: arm64
CGO_ENABLED: '0'
run: go build -trimpath -o /dev/null ./cmd/stem/
# ============================================================================
# Lighthouse Performance Audit
# ============================================================================
lighthouse:
name: Lighthouse Audit
runs-on: ubuntu-latest
needs: [changes, backend, frontend]
timeout-minutes: 15
# Skipped on draft PRs (slow); only runs when frontend changed.
# always() lets the if: evaluate when backend was skipped upstream.
if: |
always() &&
(github.event_name != 'pull_request' || github.event.pull_request.draft == false) &&
needs.frontend.result == 'success' &&
(needs.changes.outputs.frontend == 'true' || needs.changes.outputs.workflows == 'true')
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: ./.github/actions/setup-go
- name: Set up Node.js
uses: ./.github/actions/setup-node
- name: Download frontend build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: frontend-dist
path: internal/api/ui/
- name: Build backend
run: CGO_ENABLED=0 go build -o bin/stem ./cmd/stem/
- name: Start server
env:
STEM_AUTH_USERNAME: admin
STEM_AUTH_PASSWORD: admin
run: |
# Backend is HTTPS-by-default since the Wave 1 TLS rollout (the
# legacy STEM_TLS_ENABLED env var was removed). Self-signed cert
# is auto-generated on first start.
nohup ./bin/stem web -p 8444 > server.log 2>&1 &
for i in {1..30}; do
if curl -skf https://localhost:8444/health/ready 2>/dev/null; then
echo "Server ready"
exit 0
fi
sleep 1
done
cat server.log
exit 1
- name: Run Lighthouse
uses: treosh/lighthouse-ci-action@3e7e23fb74242897f95c0ba9cabad3d0227b9b18 # v12.6.2
with:
urls: |
https://localhost:8444
# configPath picks up chromeFlags for self-signed cert handling
# — stem listens HTTPS-only post-Wave-1 with a dev mkcert cert
# that the CI runner doesn't trust by default.
configPath: ./.lighthouserc.json
uploadArtifacts: true
temporaryPublicStorage: true
# ============================================================================
# E2E Browser Tests (Playwright)
# ============================================================================
e2e:
name: E2E Browser Tests
runs-on: ubuntu-latest
needs: [changes, backend, frontend]
timeout-minutes: 30
# Skipped on draft PRs (slow); also skipped if no frontend/backend changed.
# always() lets the if: evaluate when backend was skipped upstream.
if: |
always() &&
(github.event_name != 'pull_request' || github.event.pull_request.draft == false) &&
needs.frontend.result == 'success' &&
(needs.backend.result == 'success' || needs.backend.result == 'skipped')
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: ./.github/actions/setup-go
- name: Set up Node.js
uses: ./.github/actions/setup-node
- name: Download frontend build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: frontend-dist
path: internal/api/ui/
- name: Install frontend dependencies (for Playwright)
working-directory: ui
run: npm ci
- name: Build backend
run: CGO_ENABLED=0 go build -o bin/stem ./cmd/stem/
- name: Install Playwright browsers
working-directory: ui
# Per E2E_CONVENTIONS: chromium (Chrome+Edge) and webkit (Safari).
run: npx playwright install --with-deps chromium webkit
- name: Start backend server
env:
STEM_AUTH_USERNAME: admin
STEM_AUTH_PASSWORD: admin
run: |
# Backend is HTTPS-by-default since the Wave 1 TLS rollout. The
# legacy STEM_TLS_ENABLED env var was removed from the code; self-
# signed cert is auto-generated on first start. Use -k on curl
# probes; Playwright bypasses cert via PLAYWRIGHT_IGNORE_HTTPS_ERRORS.
nohup ./bin/stem web -p 8444 > server.log 2>&1 &
echo "Waiting for server to start..."
for i in {1..30}; do
if curl -skf https://localhost:8444/health/ready 2>/dev/null; then
echo "Server ready after $i attempts"
exit 0
fi
echo "Attempt $i: Server not ready, waiting..."
sleep $((i < 5 ? 1 : 2))
done
echo "Server failed to start after 30 attempts"
cat server.log
exit 1
- name: Run Playwright E2E tests
working-directory: ui
env:
E2E_BASE_URL: https://localhost:8444
PLAYWRIGHT_IGNORE_HTTPS_ERRORS: "true"
# Both chromium and webkit run; the config's projects array is now
# exactly [chromium, webkit] per E2E_CONVENTIONS.
run: npx playwright test --reporter=html
- name: Upload Playwright report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: playwright-report
path: ui/playwright-report/
retention-days: 7
- name: Upload server logs on failure
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: e2e-server-logs
path: server.log
# ============================================================================
# CI Complete — dispatcher / branch-protection aggregator
# ============================================================================
# Every required-status-check on `main` is consolidated into this single
# job. It fails if any upstream job actually failed; it passes if every
# upstream either succeeded or was intentionally skipped via the path
# filter. Branch protection requires ONLY "CI Complete" (plus "Lint PR
# Title" / "Lint PR Body" from their own workflows) rather than
# enumerating every individual job — so a docs-only PR can merge cleanly
# when backend/frontend/c are all skipped. CodeQL and license-check are
# NOT required contexts: CodeQL is advisory (and skips entirely on
# private repos) and license-check reports without blocking. The
# `semgrep` job in this file is the required SAST gate (blocking on
# ERROR severity).
ci-complete:
name: CI Complete