-
Notifications
You must be signed in to change notification settings - Fork 0
1645 lines (1469 loc) · 72.3 KB
/
Copy pathci.yml
File metadata and controls
1645 lines (1469 loc) · 72.3 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
# SPDX-FileCopyrightText: 2026 Uberware Inc. <https://uberware.net>
# SPDX-License-Identifier: AGPL-3.0-or-later
name: CI
# Runs on every push to main and on every pull request targeting main.
on:
push:
branches: [main]
pull_request:
workflow_dispatch: # allows manual triggering from the GitHub Actions UI
# If a new push arrives on the same branch while a run is in progress,
# cancel the older run. Prevents wasted minutes on stale commits.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Minimal permissions — principle of least privilege.
# Individual jobs can elevate if needed (none currently do).
permissions:
contents: read
jobs:
# ── Change detection ─────────────────────────────────────────────────────────
# Detects which areas changed so downstream jobs run only when relevant: the
# Python jobs target `clients/python/`, the submitter jobs target
# `clients/submitter/` and `presets/sqi/`, and the actionlint job targets the
# workflow files themselves.
changes:
name: Detect changed paths
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read # paths-filter reads the PR diff via the API
outputs:
python: ${{ steps.filter.outputs.python }}
submitter: ${{ steps.filter.outputs.submitter }}
presets: ${{ steps.filter.outputs.presets }}
workflows: ${{ steps.filter.outputs.workflows }}
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Filter paths
uses: dorny/paths-filter@v4
id: filter
with:
filters: |
python:
- 'clients/python/**'
- '.github/workflows/ci.yml'
submitter:
- 'clients/submitter/**'
- 'presets/sqi/**'
- '.github/workflows/ci.yml'
# Gates the native Windows ffmpeg job below. Scoped to the presets
# themselves and the code that executes them, deliberately NOT to
# internal/worker or internal/openjd: a filter wide enough to catch
# every possible influence would fire on nearly every PR, which is
# the runner cost this job was carved out to control. The tradeoff
# is that a change to the worker's format-string resolution can
# break the PowerShell join without this firing — that lands on the
# push-to-main run instead.
presets:
- 'presets/**'
- 'test/integration/ffmpeg_presets_test.go'
- 'test/integration/worker_binary_test.go'
- '.github/workflows/ci.yml'
workflows:
- '.github/workflows/**'
# ── Workflow linting ─────────────────────────────────────────────────────────
# Lints the GitHub Actions workflow files with actionlint (run via `go run`,
# pinned in the Makefile) whenever a workflow changes.
actionlint:
name: Lint workflows (actionlint)
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.workflows == 'true' || github.event_name == 'workflow_dispatch' }}
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
- name: Run actionlint
run: make lint-actions
# ── Primary CI job ───────────────────────────────────────────────────────────
# Runs build → vet → lint → test (race + coverage gate) → upload coverage.
# All steps are sequential; failure in any step stops the job.
test:
name: Build, lint, and test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
# Reads the Go and toolchain versions from go.mod automatically.
# Module download cache and build cache are enabled by default in v6.
go-version-file: go.mod
# Ensure go.mod (and go.sum once dependencies exist) are tidy, then check
# that nothing changed. Fails the build if a developer forgot to commit
# the result of go mod tidy.
- name: Tidy module
run: go mod tidy
- name: Check go.mod is committed tidy
run: git diff --exit-code go.mod go.sum
# Verifies that the on-disk module cache matches go.sum exactly.
# Catches tampered or corrupted dependencies before they can affect the build.
- name: Verify module integrity
run: go mod verify
- name: Build
run: make build
# Publish the freshly-built binaries so the Python integration job can run
# against them without rebuilding the web UI + Go toolchain itself.
- name: Upload binaries
uses: actions/upload-artifact@v7
with:
name: binaries
path: |
bin/sqi-server
bin/sqi-worker
retention-days: 1
- name: go vet
run: make vet
# Uses the official golangci-lint GitHub Action, which handles binary
# download and caching. Configuration is read from .golangci.yml at the
# repo root.
#
# version is PINNED on purpose. Left unset the action resolves "latest",
# so every new golangci-lint release lands on unrelated PRs as a red CI
# run: v2.13.1 enabled the modernize/errorsastype analyzer and broke a
# branch that had touched none of the reported code. Bump this
# deliberately, in its own commit, with the fallout fixed alongside it.
#
# There is also a FLOOR below this pin: .golangci.yml excludes
# errors.AsType from errcheck by function name, and errcheck before
# 2.13.0 cannot resolve a generic function's name, so on an older
# golangci-lint that exclusion silently fails to match and every
# errors.AsType call site is reported. The floor is 2.13.0 (what Homebrew
# ships, verified clean on this repo); this pin is one patch above it.
# Keep docs/development.md's required-version note in step with both.
#
# Explicit path patterns mirror the Makefile LINT_PKGS variable: they
# exclude web/node_modules/ (third-party JS packages that happen to
# contain Go code and are not part of the sqi codebase).
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.13.1
args: --timeout=5m ./cmd/... ./internal/... ./pkg/... ./test/... ./web
# The internal/openjd/expr path-engine differentials shell out to python3
# and compare against pathlib. sqi implements CPython 3.13 path semantics
# (PureWindowsPath.is_absolute() delegating to ntpath.isabs, and stem/suffix
# no longer treating a name's leading/trailing dot run as an extension), so
# those tests requirePython313 and SKIP on anything older — including the
# ubuntu-latest system python, which is 3.12. Pin >= 3.13 here so the
# differential actually ASSERTS rather than silently skipping. This is the
# normal test suite only; the oracle job keeps its own 3.12 pin, which is
# about the openjd-model venv, not pathlib.
- name: Set up Python (>= 3.13 for the expr path differentials)
uses: actions/setup-python@v7
with:
python-version: "3.13"
# Run the full test suite with the race detector enabled and enforce
# the minimum line-coverage threshold.
# `make test-cover` enables -race via RACE=on (the Makefile default),
# writes coverage.out, and exits non-zero if coverage < COVERAGE_MIN.
# COVERAGE_MIN defaults to 70 in the Makefile — that is the single
# source of truth; raise it there as coverage grows.
- name: Test (race detector + coverage gate)
run: make test-cover
# Uploads coverage.out to Codecov. Requires the CODECOV_TOKEN secret
# to be configured in the repository — see docs/development.md for setup
# instructions. fail_ci_if_error is false so a Codecov outage does not
# break the build.
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.out
fail_ci_if_error: false
# ── Web UI quality gates ─────────────────────────────────────────────────────
# Runs ESLint (--max-warnings 0), TypeScript type-check (tsc -b), and the
# Vitest test suite with coverage enforcement — all in parallel with the Go
# test job so neither blocks the other.
web-check:
name: Web UI lint, typecheck, and test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
# Read the required Node version from .nvmrc at the repo root.
# The engines field in web/package.json documents the same constraint.
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install web dependencies
working-directory: web
run: npm ci
# Prettier — fail if any file would be reformatted.
- name: Check formatting (Prettier)
working-directory: web
run: npm run format:check
# ESLint — --max-warnings 0 makes every warning a build failure.
- name: Lint (ESLint)
working-directory: web
run: npm run lint
# tsc -b type-checks all project references (tsconfig.app.json +
# tsconfig.node.json) without emitting output.
- name: Type-check (tsc -b)
working-directory: web
run: npm run typecheck
# Vitest run with V8 coverage; exits non-zero if thresholds are not met.
# The thresholds are defined in vite.config.ts (85 % lines / 82 % functions).
- name: Test with coverage gate (Vitest)
working-directory: web
run: npm run test:coverage
# Upload the JSON coverage report to Codecov so web coverage trends are
# tracked separately from Go (flags: web). Mirrors the Go upload step.
# fail_ci_if_error: false so a Codecov outage does not break the build.
- name: Upload web coverage to Codecov
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: web/coverage/coverage-final.json
flags: web
fail_ci_if_error: false
# ── Web end-to-end (Playwright) ──────────────────────────────────────────────
# Builds the real binaries (sqi-server embeds web/dist) and drives the embedded
# UI in Chromium, Firefox, and WebKit. The Playwright global setup boots a
# server + worker on an ephemeral loopback port, seeds a farm/queue, submits a
# job through the /submit form, and asserts it runs to completion with logs.
# See docs/cross-platform-validation.md for the manual per-OS equivalent.
web-e2e:
name: Web UI end-to-end (Playwright)
runs-on: ubuntu-latest
# The worker refuses to run as root unless explicitly allowed; CI runs as
# root, so the global setup boots it with this set.
env:
SQI_WORKER_ALLOW_ROOT: "true"
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install web dependencies
working-directory: web
run: npm ci
# Builds web/dist + both binaries into ./bin so the Playwright global
# setup finds bin/sqi-server and bin/sqi-worker (it would build them
# otherwise, but doing it here keeps the failure mode obvious).
- name: Build binaries (server embeds the UI)
run: make build
# Installs the three browser engines plus their Linux system libraries.
- name: Install Playwright browsers
working-directory: web
run: npx playwright install --with-deps
- name: Run Playwright E2E (chromium, firefox, webkit)
working-directory: web
run: npm run test:e2e
# Publish the HTML report and traces when the E2E run fails so flakes are
# diagnosable from the CI artifacts.
- name: Upload Playwright report on failure
if: ${{ failure() }}
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: web/playwright-report/
retention-days: 7
# ── Docker latest ────────────────────────────────────────────────────────────
# Builds and pushes ghcr.io/uberware/sqi/sqi-server:latest on every push to
# main. Uses the standalone multi-stage Dockerfile so the image is built from
# source without needing goreleaser. Runs only after the test job passes.
docker-latest:
name: Publish :latest to GHCR
runs-on: ubuntu-latest
needs: [test, web-check]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions:
contents: read # required by actions/checkout
packages: write
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
- name: Export GOVERSION
run: echo "GOVERSION=$(go version | awk '{print $3}')" >> "$GITHUB_ENV"
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push sqi-server :latest
uses: docker/build-push-action@v7
with:
context: .
file: deploy/docker/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ghcr.io/uberware/sqi/sqi-server:latest
build-args: |
VERSION=main
COMMIT=${{ github.sha }}
BUILD_DATE=${{ github.event.head_commit.timestamp || github.event.repository.updated_at }}
GOVERSION=${{ env.GOVERSION }}
# Per-image cache scope so the server and worker exports (both
# mode=max, run back-to-back) don't clobber each other's blob refs.
# ignore-error keeps a flaky GitHub Actions Cache export (the known
# "error writing layer blob: not_found") from failing a job whose real
# job — publishing :latest — has already succeeded.
cache-from: type=gha,scope=docker-latest-server
cache-to: type=gha,scope=docker-latest-server,mode=max,ignore-error=true
- name: Build and push sqi-worker :latest
uses: docker/build-push-action@v7
with:
context: .
file: deploy/docker/worker/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ghcr.io/uberware/sqi/sqi-worker:latest
build-args: |
VERSION=main
COMMIT=${{ github.sha }}
BUILD_DATE=${{ github.event.head_commit.timestamp || github.event.repository.updated_at }}
GOVERSION=${{ env.GOVERSION }}
cache-from: type=gha,scope=docker-latest-worker
cache-to: type=gha,scope=docker-latest-worker,mode=max,ignore-error=true
# ── Cross-compilation matrix ─────────────────────────────────────────────────
# Verifies that the binary compiles cleanly for all target platforms.
# Runs only after the test job passes (needs: test).
# fail-fast: false means a failure on one platform does not cancel the others,
# giving a complete picture of which targets are broken.
#
# `go build ./cmd/sqi-server/... ./cmd/sqi-worker/...` below proves the two
# binaries link, but `go build` never compiles _test.go files at all, so it
# cannot catch a Windows-only break in test code — which is exactly what
# happened on the task-isolation branch: internal/worker/isolation's
# POSIX-only isolation_test.go referenced SysProcAttr.Credential, silently
# breaking `GOOS=windows go vet` with nothing in CI to notice. The extra
# windows/amd64-only step compiles every internal package (not just cmd/,
# unlike the matrix build above) and vets internal/worker/... — vet, like
# test compilation, type-checks _test.go files too — so the same class of
# break fails a PR instead of surviving until someone runs it by hand.
cross-compile:
name: Cross-compile (${{ matrix.goos }}/${{ matrix.goarch }})
runs-on: ubuntu-latest
needs: test
strategy:
fail-fast: false
matrix:
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
- name: Compile
run: |
GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build ./cmd/sqi-server/...
GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build ./cmd/sqi-worker/...
# See the job's own comment above for why this exists and why plain
# `go build` (above) cannot substitute for it. amd64-only: GOARCH does
# not affect what go vet's type-checking can see, so running this a
# second time on arm64 would only cost CI minutes for no new coverage.
- name: Windows internal packages build + vet
if: matrix.goos == 'windows' && matrix.goarch == 'amd64'
run: |
GOOS=windows GOARCH=amd64 go build ./internal/... ./cmd/...
GOOS=windows GOARCH=amd64 go vet ./internal/worker/...
# ── Cross-platform runtime smoke ─────────────────────────────────────────────
# cross-compile (above) proves the binaries *build* for every target; this job
# proves they *run* — start, register a worker, execute a job, and stream logs
# over REST + WebSocket — natively on macOS and Windows. Linux runtime is
# already covered by the docker smoke-test and web-e2e jobs, so it is omitted
# here. The manual VM-based equivalent is docs/cross-platform-validation.md.
#
# Native macOS/Windows runner minutes cost ~10x Linux, so this is skipped on
# pull requests and runs only on pushes to main and manual dispatch.
# fail-fast: false so a Windows-only failure still reports the macOS result.
cross-platform-runtime:
name: Runtime smoke (${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: test
if: ${{ github.event_name != 'pull_request' }}
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
# The worker refuses to run as root/elevated unless this is set; harmless on
# the unprivileged runner users but keeps parity with the docker smoke job.
env:
SQI_WORKER_ALLOW_ROOT: "true"
# Use bash on every OS (Git Bash on Windows) so the one smoke script runs
# unchanged across platforms.
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: web/package-lock.json
# Provides a python with `websockets` so the smoke script's WebSocket
# assertion runs for real (it otherwise degrades to a logged SKIP).
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.x"
- name: Install websockets for the WS smoke check
run: python -m pip install --upgrade pip websockets
# Build the web bundle first (the server embeds web/dist), then both
# binaries with the platform-appropriate extension.
- name: Build web bundle and binaries
run: |
(cd web && npm ci && npm run build)
ext=""; [ "$RUNNER_OS" = "Windows" ] && ext=".exe"
go build -o "bin/sqi-server${ext}" ./cmd/sqi-server
go build -o "bin/sqi-worker${ext}" ./cmd/sqi-worker
# Reuses scripts/smoke.sh (the same script developers run via `make smoke`).
# Passing the prebuilt binaries skips its internal `make build`, so no GNU
# make is required on the Windows runner.
- name: End-to-end smoke (REST + WebSocket, plain / broker-auth / TLS)
run: |
ext=""; [ "$RUNNER_OS" = "Windows" ] && ext=".exe"
export SQI_SERVER_BIN="bin/sqi-server${ext}"
export SQI_WORKER_BIN="bin/sqi-worker${ext}"
# Declare and assign separately so the command substitution's exit
# status is not masked by `export` (shellcheck SC2155).
SQI_SMOKE_PYTHON="$(command -v python)"
export SQI_SMOKE_PYTHON
bash scripts/smoke.sh 2>&1 | tee smoke-output.log
status=${PIPESTATUS[0]}
# The script runs three modes. Assert each one actually reported a
# pass by name: a filter or branch regression that silently skipped a
# mode would otherwise leave this job green while covering less.
missing=0
for mode in noauth brokerauth tls; do
if ! grep -q "SMOKE TEST PASSED (mode=$mode)" smoke-output.log; then
echo "::error::smoke mode $mode did not report a pass"
missing=1
fi
done
if [ "$status" -ne 0 ] || [ "$missing" -ne 0 ]; then
echo "::error::the smoke test did not pass in all three modes (exit=$status)."
exit 1
fi
echo "Confirmed all three smoke modes passed."
# ── Docker smoke test ────────────────────────────────────────────────────────
# Builds both Docker images from source (no registry push) and runs them
# together via docker-compose.smoke.yml to verify that the worker registers
# successfully with the server. Runs on every push to main after the primary
# test job passes; also runs on pull requests as a pre-merge gate.
#
# Plain `docker build` (not docker/build-push-action) is used here so that
# setup-buildx is not needed. setup-buildx pulls moby/buildkit from Docker
# Hub at job start, which times out on congested runners. The smoke test
# only needs single-platform local builds, which the daemon's built-in
# BuildKit supports without any external image.
smoke-test:
name: Docker smoke test (server + worker)
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
- name: Export GOVERSION
run: echo "GOVERSION=$(go version | awk '{print $3}')" >> "$GITHUB_ENV"
# Build images locally using the Docker daemon's built-in BuildKit.
# docker build is used directly (no setup-buildx needed) because the
# smoke test only requires a single-platform local image.
- name: Build sqi-server image
run: |
DOCKER_BUILDKIT=1 docker build \
-f deploy/docker/Dockerfile \
--build-arg VERSION=smoke \
--build-arg COMMIT=${{ github.sha }} \
--build-arg BUILD_DATE=${{ github.event.head_commit.timestamp || github.event.pull_request.head.repo.updated_at || github.event.repository.updated_at }} \
--build-arg GOVERSION=${{ env.GOVERSION }} \
-t ghcr.io/uberware/sqi/sqi-server:latest \
.
- name: Build sqi-worker image
run: |
DOCKER_BUILDKIT=1 docker build \
-f deploy/docker/worker/Dockerfile \
--build-arg VERSION=smoke \
--build-arg COMMIT=${{ github.sha }} \
--build-arg BUILD_DATE=${{ github.event.head_commit.timestamp || github.event.pull_request.head.repo.updated_at || github.event.repository.updated_at }} \
--build-arg GOVERSION=${{ env.GOVERSION }} \
-t ghcr.io/uberware/sqi/sqi-worker:latest \
.
- name: Start smoke stack
run: docker compose -f deploy/docker-compose.smoke.yml up -d
- name: Wait for sqi-server to be ready
run: |
for i in $(seq 1 30); do
if curl -sf http://localhost:8080/readyz > /dev/null; then
echo "sqi-server is ready"
exit 0
fi
echo "Waiting for sqi-server... ($i/30)"
sleep 2
done
echo "ERROR: sqi-server did not become ready within 60 s"
docker compose -f deploy/docker-compose.smoke.yml logs sqi-server
exit 1
- name: Wait for sqi-worker to register
run: |
for i in $(seq 1 30); do
total=$(curl -sf http://localhost:8080/api/v1/workers \
| python3 -c "import sys, json; print(json.load(sys.stdin).get('total', 0))" \
2>/dev/null || echo "0")
if [ "$total" -ge 1 ]; then
echo "sqi-worker registered (total=$total)"
exit 0
fi
echo "Waiting for worker... ($i/30)"
sleep 2
done
echo "ERROR: no sqi-worker registered within 60 s"
docker compose -f deploy/docker-compose.smoke.yml logs
exit 1
- name: Tear down
if: always()
run: docker compose -f deploy/docker-compose.smoke.yml down -v
# ── Auth surface demo ────────────────────────────────────────────────────────
# Runs scripts/auth-demo.sh (the same script developers run via `make
# auth-demo`) against real binaries on a live local farm: auth enabled, a
# bootstrap admin, a second non-admin account, and a worker executing a job.
#
# This is the end-to-end regression test for the auth surface — session
# cookies, API keys, the CSRF guard, the RBAC permission matrix, and job owner
# scoping — all of which are cross-cutting enough that the Go unit tests can
# confirm each piece in isolation without catching a router that mounts the
# wrong middleware on a real route.
#
# Linux-only and unconditional: it takes well under a minute, so unlike
# cross-platform-runtime there is no reason to restrict it to pushes.
#
# The script needs curl + jq (preinstalled on GitHub-hosted runners) and
# python3 (likewise; used only to allocate free loopback ports). Passing the
# prebuilt binaries via SQI_*_BIN skips the script's internal `make build`.
#
# No Node build: the tracked placeholder web/dist/index.html is enough to keep
# the server's //go:embed valid on a clean checkout, and this job exercises
# only the REST API, never the UI bundle.
auth-demo:
name: Auth surface demo (live farm)
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
- name: Build binaries
run: |
go build -o bin/sqi-server ./cmd/sqi-server
go build -o bin/sqi-worker ./cmd/sqi-worker
- name: Auth surface demo (sessions, API keys, CSRF, RBAC, owner scoping)
env:
SQI_SERVER_BIN: bin/sqi-server
SQI_WORKER_BIN: bin/sqi-worker
run: bash scripts/auth-demo.sh
# ── LDAP: real-directory integration ─────────────────────────────────────────
# Runs the LDAP tests against an actual OpenLDAP server in a container, which
# is the only thing that exercises sqi's go-ldap *wire* usage: search scopes,
# attribute names, filter syntax, and how a real server answers a request it
# does not support. Every other LDAP test drives a fake connection and cannot
# see any of that — a wire bug passes the rest of CI and fails only against a
# live directory. This job exists so that class of bug cannot merge.
#
# Docker is preinstalled on the ubuntu runners, so the test provisions its own
# container and needs no service definition here. It skips (does not fail)
# when Docker is unavailable, which keeps it harmless on any runner that lacks
# it. Tagged `integration`, so it does not run in the default `make test`.
ldap-integration:
name: LDAP integration (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
needs: test
# Both architectures, because the container image is multi-arch and its
# variants have already proven not to be equivalent: the memberof module
# registers its schema several seconds later on amd64 than on arm64, which
# is a race a fixture can sit inside on one architecture and not the other.
# Testing one arch would leave the other free to rot — and the LDAP wire
# behavior this job exists to pin is exactly where that divergence showed
# up. Native runners rather than QEMU: emulation is several times slower
# and would test the emulator's timing rather than the runner's.
#
# fail-fast is off on purpose. When these two disagree, the disagreement IS
# the finding, and cancelling the sibling job throws away half of it.
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
# Pulled explicitly so an image-registry problem fails here, with a clear
# message, rather than inside the test where it would surface as a skip
# and quietly turn this job green without having verified anything.
- name: Pull OpenLDAP image
run: docker pull osixia/openldap:1.5.0
- name: Run LDAP integration tests
run: make test-ldap
# ── SSO against a real identity provider ─────────────────────────────────────
# The SSO unit tests drive a fake provider. It signs real tokens, so a
# validation mistake surfaces — but a fake returns whatever the test asks for,
# so it cannot show what a real provider OMITS. Keycloak emits no group
# membership at all without a protocol mapper configured for it, and a token
# with no groups still validates: every user then lands on default_role, a
# silent privilege downgrade with no error anywhere. This job is what makes
# that class of bug fail before it merges. It also pins the two vendor
# behaviors the logout design rests on (see test/integration/oidc_test.go).
#
# ONE architecture, unlike ldap-integration above, and deliberately so rather
# than by oversight: that job runs both because the OpenLDAP image's arm64 and
# amd64 variants have already proven to differ in real, test-visible ways
# (cn=config credentials, overlay instantiation, module-registration timing).
# No such divergence is known for the Keycloak image — it is a JVM application
# whose HTTP and token behavior is what this job asserts on, none of which is
# architecture-dependent — so a second runner would cost time without pinning
# anything the first does not. Revisit if an arch-specific difference ever
# does show up.
#
# Docker is preinstalled on the ubuntu runners, so the test provisions its own
# container and needs no service definition here. It skips (does not fail)
# when Docker is unavailable. Tagged `integration`, so it does not run in the
# default `make test`.
oidc-integration:
name: OIDC integration
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
# Pulled explicitly so an image-registry problem fails here, with a clear
# message, rather than inside the test where it would surface as a skip
# and quietly turn this job green without having verified anything.
#
# Keep this tag in step with keycloakImage in test/integration/oidc_test.go:
# the test scrapes Keycloak's login form, so the pin is load-bearing.
- name: Pull Keycloak image
run: docker pull quay.io/keycloak/keycloak:26.0.7
- name: Run OIDC integration tests
run: make test-oidc
# ── Task isolation: run-as-user integration ──────────────────────────────────
# Runs the run-as-user isolation suite as REAL root against REAL unprivileged
# OS accounts inside a container — the only thing that exercises uid/gid
# switching, session-directory ownership, environment filtering, and
# process-group kill. internal/worker/isolation's fake Provider is
# structurally blind to all of it; three Critical defects on this branch were
# caught only because this suite runs against a real container. This job is
# what makes that class of bug fail before it merges.
#
# Both architectures, matching ldap-integration above and for the same
# reason: this exercises syscall-level behavior (setuid/setgid/setgroups,
# SysProcAttr.Credential), which is exactly where arch divergence could hide.
# oidc-integration's single-arch choice does not transfer here — its
# justification is "no arch divergence is known for a JVM app", and this is
# the opposite of that: raw kernel syscalls, not a JVM's HTTP/token behavior.
# Native runners rather than QEMU, same reasoning as ldap-integration:
# emulation is several times slower and would test the emulator's timing
# rather than the runner's.
#
# `make test-isolation` builds its own image from a staged copy of the repo
# (see the Makefile comment above the target), so unlike ldap-integration/
# oidc-integration it needs no image pre-pulled here.
#
# IMPORTANT: `make test-isolation` exits 0 with a message when Docker is
# unavailable — correct on a developer machine that may not have Docker, but
# on a CI runner (where Docker is always preinstalled) a skip here would be a
# silent false-green with nothing verified. So this job does not trust the
# target's own exit code: it captures the output and fails unless (1) no
# Docker-unavailable skip message appears and (2) all 11 TestIsolation_ tests
# are confirmed PASS by name. A silent skip, a build failure, or a partial
# run all fail the job instead of passing quietly.
isolation-integration:
name: Isolation integration (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
needs: test
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
# Does not trust a bare exit-0 from `make test-isolation`: asserts the
# suite actually executed (no Docker-unavailable skip) and that every one
# of the 11 TestIsolation_ tests is confirmed PASS by name, so a silent
# skip or a partial run fails the job instead of passing quietly.
- name: Run isolation integration tests
run: |
set +e
make test-isolation 2>&1 | tee isolation-output.log
status=${PIPESTATUS[0]}
set -e
if grep -q "docker unavailable" isolation-output.log; then
echo "::error::Docker was unavailable — the isolation suite did not run. A skip here proves nothing, so this is a hard failure."
exit 1
fi
pass_count=$(grep -c '^--- PASS: TestIsolation_' isolation-output.log || true)
expected=11
if [ "$status" -ne 0 ] || [ "$pass_count" -ne "$expected" ]; then
echo "::error::Expected $expected TestIsolation_ tests to pass (exit=$status, found=$pass_count). Failing so a partial or failed run cannot look green."
exit 1
fi
echo "Confirmed $pass_count/$expected TestIsolation_ tests passed."
# ── Task isolation: run-as-user integration (Windows) ────────────────────────
# Runs the Windows run-as-user isolation suite against REAL local accounts,
# with its privileged tier running as SYSTEM — the Windows counterpart to
# isolation-integration above, which covers POSIX.
#
# Two tiers because they need different privileges. CreateProcessAsUser —
# which os/exec invokes whenever SysProcAttr.Token is set — requires
# SeAssignPrimaryTokenPrivilege, held by default only by LocalSystem and the
# LOCAL/NETWORK SERVICE accounts. The windows-latest runner user is an
# administrator and does NOT hold it, so scripts/test-isolation-windows.ps1
# registers a scheduled task with /ru SYSTEM for the tests that launch a
# process as the target account (TestIsolationWindowsSystem_*). Everything
# else — ACL shape, credential storage, impersonated access checks
# (TestIsolationWindows_*) — runs directly under the elevated admin shell.
#
# One arch, matching oidc-integration's rationale rather than
# ldap-integration's or isolation-integration's: the Win32 APIs this suite
# exercises (ACLs, LogonUserW, CreateProcessAsUser) are not architecture-
# dependent, and hosted windows-arm64 runners are not generally available.
#
# This job also runs the Windows-only UNIT tests, because nothing else in CI
# does. cross-platform-runtime is a runtime smoke job and is skipped on pull
# requests (if: github.event_name != 'pull_request'); the only other Windows
# coverage is the cross-compile job's `GOOS=windows go vet`, which
# type-checks test files but never executes them. Package list below is
# every package with genuine `GOOS`-conditional behavior (a `_windows.go`
# file, or a `runtime.GOOS == "windows"` branch in its tests) rather than
# every package that merely imports something isolation-related:
# internal/worker/isolation, internal/worker/executor, internal/worker/staging,
# and cmd/sqi-worker each carry dedicated _windows.go/_windows_test.go files;
# internal/worker/envutil and internal/worker/session have no such files but
# branch on runtime.GOOS in their test bodies (path separators, credential
# handling, profile-env resolution), so running them only on Linux would
# silently skip the Windows branch of every one of those tests.
#
# IMPORTANT: scripts/test-isolation-windows.ps1 exits 0 with a message when
# the shell is not elevated, mirroring how `make test-isolation` exits 0
# without Docker. A bare exit 0 therefore proves NOTHING, which is why the
# assertion step below checks for the not-elevated message and then counts
# named PASS lines instead of trusting the exit code — the same shape as
# isolation-integration's own assertion step above.
isolation-integration-windows:
name: Isolation integration (windows)
runs-on: windows-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
# Windows-only unit tests. See the job comment above for why these six
# packages and not others. -count=1 disables the test cache so a stale
# pass from an earlier step can't stand in for a real run.
- name: Windows unit tests
shell: bash
run: |
go test -count=1 \
./internal/worker/isolation/... \
./internal/worker/executor/... \
./internal/worker/envutil/... \
./internal/worker/session/... \
./internal/worker/staging/... \
./cmd/sqi-worker/...
- name: Run windows isolation integration tests
shell: pwsh
run: |
powershell -NoProfile -ExecutionPolicy Bypass `
-File scripts/test-isolation-windows.ps1 2>&1 | Tee-Object -FilePath isolation-windows.log
# Does not trust a bare exit-0 from the script: asserts the suite
# actually executed (the runner was elevated) and that every one of the
# 16 named tests — 8 TestIsolationWindows_* (elevated admin) plus
# 8 TestIsolationWindowsSystem_* (SYSTEM) — is confirmed PASS by name, so
# a silent skip, a build failure, or a partial run all fail the job
# instead of passing quietly.
- name: Assert the suite actually ran
shell: bash
run: |
if grep -q "not elevated" isolation-windows.log; then
echo "::error::The runner was not elevated — the isolation suite did not run. A skip here proves nothing, so this is a hard failure."
exit 1
fi
expected=(
TestIsolationWindows_CapableRejectsPlainAdmin
TestIsolationWindows_ChownRecursiveDoesNotFollowJunction
TestIsolationWindows_CredentialDirectoryExcludesUnprivilegedTrustees
TestIsolationWindows_CredentialFileNotReadableByRunAsAccount
TestIsolationWindows_CredentialMissingIsActionable
TestIsolationWindows_CredentialRoundTrip
TestIsolationWindows_SecondAccountCannotOpenSessionDir
TestIsolationWindows_SessionDirACLIsProtected
TestIsolationWindowsSystem_Capable
TestIsolationWindowsSystem_ChildCanWriteSessionDir
TestIsolationWindowsSystem_ChildRunsAsTargetUser
TestIsolationWindowsSystem_CrossUserSessionDirDenied
TestIsolationWindowsSystem_DaemonEnvNotInherited
TestIsolationWindowsSystem_NoIsolationUnchanged
TestIsolationWindowsSystem_ProfileEnvPointsAtTargetUser
TestIsolationWindowsSystem_RunsAsSystem
)
missing=0
for name in "${expected[@]}"; do
if ! grep -q -- "--- PASS: $name" isolation-windows.log; then
echo "::error::$name did not pass"
missing=1
fi
done
if [ "$missing" -eq 0 ]; then
echo "Confirmed all ${#expected[@]}/16 named isolation tests passed."
fi
exit $missing
# ── Integration suite (whole package) ────────────────────────────────────────
# Runs every file under test/integration/ via `make test-integration`, rather
# than naming individual tests in a -run regex the way the LDAP, OIDC, and
# isolation jobs below do. Those three name their tests because each needs
# its own runner matrix or container prerequisites; everything else in the
# package — broker auth, EXPR parameter binding and end-to-end execution,
# failure-reason propagation (both in-process and against a real worker
# binary), retry/auto-park behavior, and product retry-override submission —
# shares no such prerequisite, so a single whole-package run covers a new
# test the moment it is added instead of only if someone also remembers to
# list it in a job. `go build`s a real sqi-worker binary along the way (see
# worker_binary_test.go); no other tooling is required beyond Go itself.
#
# LDAP, OIDC, and isolation tests live in this same package and this job
# exercises them too, but ubuntu-latest ships Docker so they are expected to
# run rather than skip; the LDAP and Keycloak images are pulled explicitly
# first so a registry hiccup fails here with a clear message instead of
# inside the test as a cold-pull timeout that would read as a skip. The
# isolation tests, in contrast, require running as root (see
# isolation-integration below) and are expected to skip on this ordinary
# runner — that is fine, and this job asserts nothing about them by name.