forked from multica-ai/multica
-
Notifications
You must be signed in to change notification settings - Fork 0
843 lines (752 loc) · 38.8 KB
/
Copy pathci.yml
File metadata and controls
843 lines (752 loc) · 38.8 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
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "23 3 * * *"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# PRs and main pushes use the same paths. Scheduled/manual runs validate all
# scopes, including platform stress tests, without charging every merge for them.
changes:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
frontend: ${{ steps.decide.outputs.frontend }}
backend: ${{ steps.decide.outputs.backend }}
sqlc: ${{ steps.decide.outputs.sqlc }}
runtime: ${{ steps.decide.outputs.runtime }}
quality_only: ${{ steps.decide.outputs.quality_only }}
scripts: ${{ steps.decide.outputs.scripts }}
installer: ${{ steps.decide.outputs.installer }}
images: ${{ steps.decide.outputs.images }}
full: ${{ steps.decide.outputs.full }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- name: Test CI selection and gates
run: node --test scripts/ci-scope.test.mjs scripts/check-image-budget.test.mjs
- name: Filter paths
id: filter
if: github.event_name == 'pull_request' || github.event_name == 'push'
uses: dorny/paths-filter@v3
with:
# Pushes compare before/head SHAs. PRs use the pull-request file API;
# paths-filter ignores base/ref for pull_request events.
base: ${{ github.event_name == 'push' && github.event.before || github.base_ref }}
ref: ${{ github.sha }}
filters: .github/ci-paths.json
- name: Decide
id: decide
env:
EVENT_NAME: ${{ github.event_name }}
FILTER_RESULTS: ${{ toJSON(steps.filter.outputs) }}
run: node scripts/ci-scope.mjs decide
sqlc-check:
needs: changes
if: ${{ needs.changes.outputs.sqlc == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.26.x"
check-latest: true
cache-dependency-path: server/go.sum
- name: Verify generated SQL is up to date
run: |
make sqlc
git diff --exit-code -- server/pkg/db/generated
untracked="$(git ls-files --others --exclude-standard -- server/pkg/db/generated)"
if [ -n "$untracked" ]; then
printf 'Untracked generated SQL files:\n%s\n' "$untracked"
exit 1
fi
# The frontend validation is split across four runners on purpose. A prior
# split isolated build/typecheck/lint from the test suites; on a full test
# cache miss, however, web/core/desktop/views still competed for the four
# vCPUs on the test runner and stretched the job to ~8 minutes. Giving views
# its own runner only reduced that to 7m23s because the suite itself remained
# the long pole, so two Vitest shards now split it across two runners while
# the remaining suites continue in parallel on another. The extra setup
# increases total runner-minutes but reduces wall-clock feedback time.
frontend-build:
needs: changes
if: ${{ needs.changes.outputs.frontend == 'true' }}
runs-on: ubuntu-latest
env:
# Pin turbo's filesystem cache somewhere actions/cache can address.
TURBO_CACHE_DIR: .turbo/cache
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install
# `node-version: 22` above floats across patch releases, and turbo's
# global hash does not include the interpreter at all (`engines` is null
# in its dry-run cache inputs). Without the resolved version in the key,
# a runner silently moving to another 22.x would restore a cache built by
# the old interpreter and report green without executing anything.
- name: Resolve runtime for cache key
id: runtime
run: echo "node=$(node --version)" >> "$GITHUB_OUTPUT"
# Cache entries are immutable, so the key carries the commit SHA to make
# every run publish a fresh one and `restore-keys` falls back to the most
# recent prefix match. Each frontend worker runs a disjoint task set, so
# it gets its own prefix rather than racing to save one key.
# GitHub scopes caches by branch: a PR reads main's entries (so unchanged
# tasks hit on the first push) and writes its own (so re-pushes hit too).
- name: Restore turbo cache
uses: actions/cache@v6
with:
path: .turbo/cache
key: turbo-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.runtime.outputs.node }}-${{ github.sha }}
restore-keys: |
turbo-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.runtime.outputs.node }}-
- name: Build, type check, and lint
# Mobile lives in a parallel mobile-verify workflow (path-filtered
# to apps/mobile/** + packages/core/**) so it doesn't add
# ~50s of expo-lint + tsc to every web/desktop PR. Keep this
# filter in sync with the root package.json scripts, which also
# exclude @multica/mobile.
run: pnpm exec turbo build typecheck lint --filter='!@multica/docs' --filter='!@multica/mobile'
- name: Check frontend quality
uses: ./.github/actions/frontend-quality
frontend-test:
needs: changes
if: ${{ needs.changes.outputs.frontend == 'true' }}
runs-on: ubuntu-latest
env:
TURBO_CACHE_DIR: .turbo/cache
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Resolve runtime for cache key
id: runtime
run: echo "node=$(node --version)" >> "$GITHUB_OUTPUT"
# See frontend-build for the key strategy. These entries are tiny (~60KB
# measured): `test` declares no outputs, so turbo caches exit codes and
# logs rather than artifacts -- yet a hit still skips the whole suite,
# which is the single most expensive task in the graph.
- name: Restore turbo cache
uses: actions/cache@v6
with:
path: .turbo/cache
key: turbo-test-${{ runner.os }}-${{ runner.arch }}-${{ steps.runtime.outputs.node }}-${{ github.sha }}
restore-keys: |
turbo-test-${{ runner.os }}-${{ runner.arch }}-${{ steps.runtime.outputs.node }}-
- name: Test web, core, and desktop
# Same filter rationale as frontend-build. Type errors are not this
# job's responsibility -- frontend-build owns the `typecheck` task.
# `test` reaches dependency sources through the hash-only
# `^cache-inputs` edge (see turbo.json), so no `tsc` runs here.
run: pnpm exec turbo test --filter='!@multica/docs' --filter='!@multica/mobile' --filter='!@multica/views'
# Split views across two runners. GitHub reports the matrix job's combined
# result, so the aggregate gate can continue depending on this one job ID.
frontend-views-test:
name: frontend-views-test (${{ matrix.index }}/${{ matrix.total }})
needs: changes
if: ${{ needs.changes.outputs.frontend == 'true' }}
strategy:
fail-fast: false
matrix:
include:
- index: 1
total: 2
- index: 2
total: 2
runs-on: ubuntu-latest
env:
TURBO_CACHE_DIR: .turbo/cache
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Resolve runtime for cache key
id: runtime
run: echo "node=$(node --version)" >> "$GITHUB_OUTPUT"
# Each shard has its own prefix so concurrent jobs never race to save the
# same immutable cache key. Turbo also includes the passthrough `--shard`
# argument in the task hash, while dependency sources still flow through
# turbo.json's hash-only `^cache-inputs` edge.
- name: Restore turbo cache
uses: actions/cache@v6
with:
path: .turbo/cache
key: turbo-views-test-${{ matrix.index }}-of-${{ matrix.total }}-${{ runner.os }}-${{ runner.arch }}-${{ steps.runtime.outputs.node }}-${{ github.sha }}
restore-keys: |
turbo-views-test-${{ matrix.index }}-of-${{ matrix.total }}-${{ runner.os }}-${{ runner.arch }}-${{ steps.runtime.outputs.node }}-
- name: Test views shard ${{ matrix.index }}/${{ matrix.total }}
run: pnpm exec turbo test --filter='@multica/views' -- --shard=${{ matrix.index }}/${{ matrix.total }}
# Preserve the aggregate check names while validating intentional skips.
frontend:
needs: [changes, frontend-build, frontend-test, frontend-views-test, frontend-quality]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Check frontend job results
env:
NEEDS_JSON: ${{ toJSON(needs) }}
JOB_SCOPES: '{"frontend-build":"frontend","frontend-test":"frontend","frontend-views-test":"frontend","frontend-quality":"quality_only"}'
run: node scripts/ci-scope.mjs gate
# The Go caches are handled explicitly here rather than through setup-go's
# `cache: true`. setup-go keys its entry on go.sum alone and shares that key
# with every Linux job in this workflow, so whichever job finished first,
# go-vulnerability-scan in under a minute, saved a ~95 MB entry holding
# little beyond module downloads; this job then "hit" that key on every run
# and never saved its own. The result was a from-scratch race-instrumented
# compile of the whole module on every backend run, measured at roughly
# half of the job's ~8 minutes (MUL-7280).
#
# Two entries now. The module cache is keyed by go.sum: immutable, saved on
# a miss, never grows. The build cache is keyed by commit SHA with a prefix
# fallback, so each run starts from the newest entry and recompiles only
# what its change touched. Only pushes to main publish a build-cache entry;
# PR runs restore and leave, which keeps storage churn at one entry per
# merge, the same shape as the turbo caches above. The key carries the
# resolved Go version because `check-latest` floats across patch releases,
# and the runner image (ImageOS) because cached cgo and race objects are
# linked against that image's glibc: when ubuntu-latest moves to a new
# release, an entry built on the old one must not be restored. setup-go
# keys on ImageOS for the same reason (actions/setup-go#368).
#
# This job is the only writer of either entry. backend-agent-tests restores
# both, but it downloads just pkg/agent's slice of the module graph and
# finishes first, so letting it save would repeat the first-writer-wins
# problem above: an immutable go.sum key pinned to a partial module cache.
#
# Every compile below runs in race mode so build, vet and test share one set
# of objects. A plain `go build` fed nothing to the -race test binaries and
# was pure duplicate work; nothing in server/ carries a `race` build tag, so
# both modes accept exactly the same code.
#
# `GOFLAGS=-count=1` because a rolling cache would otherwise let `go test`
# replay cached results. Migrations are read from disk at run time rather
# than embedded, so a schema change is invisible to a test binary's hash and
# a cached "ok" from an untouched package could mask a real failure.
backend-tests:
needs: changes
if: ${{ needs.changes.outputs.backend == 'true' }}
runs-on: ubuntu-latest
# Job-level filtering skips both services on unrelated changes.
services:
postgres:
image: pgvector/pgvector:pg17
env:
POSTGRES_DB: multica
POSTGRES_USER: multica
POSTGRES_PASSWORD: multica
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U multica -d multica"
--health-interval 5s
--health-timeout 5s
--health-retries 20
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgres://multica:multica@localhost:5432/multica?sslmode=disable
# Wires up the RedisLocalSkill*_test.go suite. Distinct from REDIS_URL
# (which would flip the server binary itself onto the Redis-backed
# realtime relay + request stores); the tests talk to this Redis
# directly so they run alongside the Postgres-backed suite.
REDIS_TEST_URL: redis://localhost:6379/1
GOFLAGS: -count=1
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Go
id: go
uses: actions/setup-go@v5
with:
# Deliberately independent from the minimum patch in server/go.mod:
# CI follows the newest 1.26 patch available to setup-go.
go-version: "1.26.x"
check-latest: true
cache: false
- name: Restore Go module cache
uses: actions/cache@v6
with:
path: ~/go/pkg/mod
key: go-mod-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('server/go.sum') }}
# ImageOS is a runner environment variable, not a context value, so a
# shell step spells the build-cache key prefix once for restore and save.
- name: Resolve Go build cache key
id: gocache
run: echo "prefix=go-build-race-${{ runner.os }}-${{ runner.arch }}-$ImageOS-${{ steps.go.outputs.go-version }}-" >> "$GITHUB_OUTPUT"
- name: Restore Go build cache
uses: actions/cache/restore@v6
with:
path: ~/.cache/go-build
key: ${{ steps.gocache.outputs.prefix }}${{ github.sha }}
restore-keys: ${{ steps.gocache.outputs.prefix }}
- name: Build
run: cd server && go build -race ./...
# The `agentintegration` files execute real agent CLIs, so no CI job runs
# them. Nothing compiled them either, which let them rot silently: a
# helper renamed in an ordinary test file, or two branches each defining
# the same helper in one package, breaks the tagged build with every
# untagged job still green. vet type-checks test files, so this is the
# cheapest gate that keeps them honest without needing an agent CLI.
- name: Vet tag-gated agent integration tests
run: cd server && go vet -race -tags agentintegration ./...
- name: Run migrations
run: cd server && go run ./cmd/migrate up
- name: Verify Go test wrapper
run: bash scripts/test-go.test.sh
- name: Test
# pkg/agent runs on its own runner: backend-agent-tests below.
run: bash scripts/test-go.sh --race --only regular
# Publishes from main only; see the job comment. `!cancelled()` so a red
# main still refreshes the objects for the next run, and the key step's
# outcome so a run that never reached Go does not save an empty entry
# under a half-formed key.
- name: Save Go build cache
if: ${{ !cancelled() && github.event_name == 'push' && steps.gocache.outcome == 'success' }}
uses: actions/cache/save@v6
with:
path: ~/.cache/go-build
key: ${{ steps.gocache.outputs.prefix }}${{ github.sha }}
# pkg/agent used to run as the tail of backend-tests: ~2m20s for a single
# package, throttled to -parallel 2 because its subprocess-backed tests carry
# hard deadlines and starve when a race build competes for the runner (see
# scripts/test-go.sh). Those tests are timer-bound rather than CPU-bound, so
# the throttle stays and the package simply gets its own runner; it reads no
# database, so no service containers either. Same trade as the frontend
# split: more runner-minutes, less wall clock. Both Go caches are restored
# but never saved here: backend-tests owns publishing (see its comment), and
# this job's only unique output is the pkg/agent test binary, which is cheap
# to relink.
backend-agent-tests:
needs: changes
if: ${{ needs.changes.outputs.backend == 'true' }}
runs-on: ubuntu-latest
env:
# Same policy as backend-tests: CI always executes the tests it reports.
GOFLAGS: -count=1
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Go
id: go
uses: actions/setup-go@v5
with:
go-version: "1.26.x"
check-latest: true
cache: false
- name: Restore Go module cache
uses: actions/cache/restore@v6
with:
path: ~/go/pkg/mod
key: go-mod-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('server/go.sum') }}
# Must match backend-tests' key step exactly.
- name: Resolve Go build cache key
id: gocache
run: echo "prefix=go-build-race-${{ runner.os }}-${{ runner.arch }}-$ImageOS-${{ steps.go.outputs.go-version }}-" >> "$GITHUB_OUTPUT"
- name: Restore Go build cache
uses: actions/cache/restore@v6
with:
path: ~/.cache/go-build
key: ${{ steps.gocache.outputs.prefix }}${{ github.sha }}
restore-keys: ${{ steps.gocache.outputs.prefix }}
- name: Test agent package
run: bash scripts/test-go.sh --race --only agent
# Keep the live vulnerability database from hiding build and test results.
# The aggregate backend job below still treats both jobs as one merge gate.
go-vulnerability-scan:
needs: changes
if: ${{ needs.changes.outputs.backend == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v5
with:
# Deliberately independent from the minimum patch in server/go.mod:
# CI follows the newest 1.26 patch available to setup-go.
go-version: "1.26.x"
check-latest: true
cache-dependency-path: server/go.sum
- name: Scan Go vulnerabilities
working-directory: server
run: go tool govulncheck ./...
# Preserve the existing required-check name while allowing tests, generated
# SQL verification, and the vulnerability scan to run independently.
backend:
needs: [changes, backend-tests, backend-agent-tests, sqlc-check, go-vulnerability-scan, macos-runtime, windows-execenv, script-checks, installer]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Check backend job results
env:
NEEDS_JSON: ${{ toJSON(needs) }}
JOB_SCOPES: '{"backend-tests":"backend","backend-agent-tests":"backend","sqlc-check":"sqlc","go-vulnerability-scan":"backend","macos-runtime":"full","windows-execenv":"runtime","script-checks":"scripts","installer":"installer"}'
run: node scripts/ci-scope.mjs gate
windows-execenv:
# The environment-preparation deadline owns a process tree, not just a Go
# process. This Windows runtime test verifies Job Object cancellation kills
# a delayed descendant before an immediate retry can reuse the same root.
# The runtime scope includes daemon/agent sources and their Go dependencies.
needs: changes
if: ${{ needs.changes.outputs.runtime == 'true' }}
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v5
with:
# Deliberately independent from the minimum patch in server/go.mod:
# CI follows the newest 1.26 patch available to setup-go.
go-version: "1.26.x"
check-latest: true
cache-dependency-path: server/go.sum
- name: Test Windows execution-environment isolation
working-directory: server
# Keep this job scoped to the runtime regression it exists to prove.
# The package's legacy OpenClaw HOME tests are not Windows-safe and are
# outside this PR; the normal backend job still runs the full package.
run: go test ./internal/daemon/execenv -run '^TestPrepareIsolated_WindowsKillsDescendantBeforeRetry$' -count=1 -timeout=5m
- name: Test Windows daemon local-skill discovery
working-directory: server
# These fixtures must redirect both the Windows user profile and the
# platform-native Hermes home; otherwise discovery scans the runner's
# real skills instead of the test directories.
run: go test ./internal/daemon -v -run '^(TestListRuntimeLocalSkills_HermesFollowsTaskHome|TestLocalSkills_DiscoversACPProviderRoots)$' -count=1 -timeout=5m
- name: Test Windows agent launcher argv/stdin handling
working-directory: server
# Agent prompts must never reach a Windows launcher through argv: the
# official cursor-agent.ps1, pi.ps1, and qwen.ps1 launch native children with
# `$args`, and PowerShell re-serialises them onto the child command
# line. Under
# Legacy native argument passing (powershell.exe 5.1, pwsh <= 7.2) a
# prompt holding embedded quotes is re-tokenised and fragments like
# `-X` become flags (#5649). Only a real PowerShell host proves this,
# so it cannot live in the ubuntu backend job. Scoped to the launcher
# tests, which are windows-tagged and therefore run nowhere else today;
# the backend job still runs the full package on Linux.
# -v so a silent skip (no PowerShell host resolved, or a -run pattern
# that stops matching) is visible in the log instead of passing as "ok".
run: go test ./pkg/agent -v -run '^(TestCursorExecutePromptSurvivesPowerShellShim|TestPiExecutePromptSurvivesPowerShellShim|TestQwenExecutePromptSurvivesPowerShellShim|TestPlatformCursorInvocation|TestPlatformCopilotInvocation|TestPlatformPiInvocation|TestPlatformQwenInvocation)' -count=1 -timeout=5m
- name: Test Windows OpenCode oversized prompt reaches stdin
working-directory: server
# #6538: the daemon inlined the whole task prompt as an argv element,
# so every OpenCode task whose prompt cleared CreateProcess's 32,767
# character lpCommandLine limit failed to start at all, with Go
# reporting ERROR_FILENAME_EXCED_RANGE as the misleading "The filename
# or extension is too long". Only a real CreateProcess enforces that
# ceiling, so this cannot run in the ubuntu backend job. The test
# spawns a native .exe directly (a Chocolatey-installed opencode.exe is
# a real PE binary, not a .cmd shim) with an oversized prompt and pins
# that the process starts, argv stays free of the prompt, and the full
# payload arrives on stdin.
# -v so a silent skip or a -run pattern that stops matching is visible
# in the log instead of passing as "ok".
run: go test ./pkg/agent -v -run '^TestOpencodeExecuteOversizedPromptStartsOnWindows$' -count=1 -timeout=5m
- name: Test Windows agent process-tree ownership
working-directory: server
# A Job Object is the only way to prove whole-tree termination on
# Windows, and only a real Windows runner can exercise it: that a
# grandchild dies with the tree that owns it, that an unowned process
# still reports cleanup as unconfirmed, and that a descendant holding
# inherited stdout neither keeps Result blocked nor survives cleanup.
# -v makes RUN/PASS evidence explicit in CI logs.
run: go test ./pkg/agent -v -run '^(TestStartOwnedProcessTreeCapturesImmediateDescendants|TestStartOwnedProcessTreeLeavesNoSuspendedChild|TestWaitProcessGroupGoneWithoutOwnershipReportsUnconfirmed|TestCodexInitializeRetrySupportedWithOwnedProcessTree|TestCodexWindowsDescendantsDieWithTheOwnedProcessTree|TestCodeArtsWindowsCancellationTerminatesDescendants)$' -count=1 -timeout=5m
- name: Test bounded CLI output collection on Windows
working-directory: server
# MUL-5467: RunCollect / RunCollectQuiet own the pipes as well as the
# process tree on the OpenClaw CLI paths, which outputOwned cannot. A real
# Windows host proves its Job Object owns and
# terminates descendants, a CLI that prints its answer and then refuses
# to exit still yields that answer, a response still streaming at the
# deadline is NOT reported as success, and no collector goroutine
# outlives the call. Unix uses process groups, so it cannot exercise this
# platform lifecycle.
# -v so a skip (a -run pattern that stops matching) is visible in the log
# instead of passing as "ok".
run: go test ./pkg/agent -v -run '^TestWindowsRunCollect' -count=1 -timeout=5m
- name: Test Windows Pi session lock leaves the transcript readable
working-directory: server
# #7840: the session lock claimed the transcript itself, and Windows
# byte-range locks are mandatory. Pi's own read of the file we hand it
# through --session failed with EBUSY, so every Pi/omp task on Windows
# died ~0.5s after launch. Unix flock is advisory and never reproduces
# it, which is exactly how the regression shipped; the tests are
# windows-tagged and therefore run nowhere else today.
# -v so a skip (a -run pattern that stops matching) is visible in the
# log instead of passing as "ok".
run: go test ./pkg/agent -v -run '^TestPiSessionFileLock' -count=1 -timeout=5m
- name: Test Windows OpenClaw npm shim interpreter resolution
working-directory: server
# #6061: every OpenClaw task failed execenv prep on a Windows host with
# a bare `exit status 1` and no stderr. A batch shim resolves and runs
# fine while the `node` it re-execs is unreachable, and npm's real
# template prefers a co-located node.exe over PATH — none of which can
# be proven without a real cmd.exe host. These tests pin: the positive
# control (node on PATH → success), that a missing node surfaces
# cmd.exe's own stderr (the first run of this job disproved #6061's
# premise that it does not), that a genuinely silent shim DOES reach the
# new diagnostic, that a co-located interpreter is credited, that a
# context timeout is not misdiagnosed as a missing interpreter, and that
# TEMP/TMP are NOT load-bearing (the originally reported root cause,
# since retracted upstream).
# Scoped to the windows-tagged shim tests — the package's legacy
# OpenClaw HOME tests are not Windows-safe; the backend job still runs
# the full package plus the cross-platform half on Linux.
# -v so a skip (no node on the runner) is visible instead of passing
# silently as "ok".
run: go test ./internal/daemon/execenv -v -run '^TestWindowsOpenclawShim' -count=1 -timeout=5m
- name: Test Windows isolated repo checkout is committable
working-directory: server
# #6449: on Windows the daemon now hands Codex tasks a checkout whose
# .git lives inside the task workdir, because a linked worktree's
# external gitdir stays read-only under the native sandbox and breaks
# `git add` / `git commit` at the end of a task. Two of the guarantees
# are claims about Windows itself and cannot be made on ubuntu: that
# Git puts the gitdir inside the task directory, and that the clone's
# objects are private copies rather than NTFS hard links sharing one
# file and one security descriptor with the daemon-owned cache. The
# cross-volume test covers what a hard link cannot express at all.
# Scoped to the two windows-tagged tests by exact name. A prefix match
# also caught the package's cross-platform isolated-checkout test,
# which cannot run here: repocache derives a cache directory name from
# the full source repo path, so a t.TempDir() path that already embeds
# a long test name doubles and blows past MAX_PATH. That test belongs
# to the ubuntu backend job, which runs the whole package.
# -v so a skip (single-volume runner) is visible instead of passing
# silently as "ok".
run: go test ./internal/daemon/repocache -v -run '^(TestIsolatedCheckoutIsCommittableOnWindows|TestIsolatedCheckoutAcrossVolumesOnWindows)$' -count=1 -timeout=5m
- name: Test Windows directory-junction link safety
working-directory: server
# MUL-6000: the per-task codex-home links the user's real skills into
# the task directory instead of copying them. On Windows that link is a
# directory junction whenever os.Symlink is denied (no Developer Mode),
# and a junction is the one link shape a ModeSymlink check misses:
# since Go 1.23 os.Lstat reports it as ModeDir|ModeIrregular with no
# ModeSymlink bit, while its DirEntry still answers IsDir() == true, so
# filepath.WalkDir descends into the target. Two claims about Windows
# itself cannot be made on ubuntu: that the per-task skills wipe
# (os.RemoveAll) drops the junction rather than the user's files, and
# that the GC's artifact sweep and size accounting refuse to walk
# through one. The tests call mklink /J directly so the junction shape
# is exercised even on a runner where symlinks are permitted.
# -v so a skip is visible instead of passing silently as "ok".
run: |
go test ./internal/daemon/execenv -v -run '^(TestSeedUserCodexSkills|TestHydrateCodexSkills)' -count=1 -timeout=5m
go test ./internal/daemon -v -run '^(TestCleanTaskArtifacts_DoesNotFollowDirectoryJunction|TestTaskSize_DoesNotCountDirectoryJunction)$' -count=1 -timeout=5m
- name: Test Windows task temp dir sweep survives a sharing violation
working-directory: server
# The GC's task temp sweep has to cope with a file it cannot delete: a
# leftover child process holding one open is what left 174 directories
# behind in #7364. Only a real Windows filesystem produces that sharing
# violation — on unix an open file unlinks fine — so this is the only
# place we can prove the two things that matter: the failed cleanup
# keeps .task_lock (without it the directory reads as a pre-lock
# leftover and stops being reclaimable on liveness), and the next cycle
# removes the directory once the handle closes.
# -v so a skip is visible instead of passing silently as "ok".
run: go test ./internal/daemon/execenv -v -run '^TestPruneTaskTempDirsSurvivesRealSharingViolation$' -count=1 -timeout=5m
- name: Test Windows agent executable junction resolution
working-directory: server
# The standalone Codex installer exposes bin as a directory junction.
# filepath.EvalSymlinks cannot traverse that reparse-point shape, so
# only a real Windows filesystem proves both PATH discovery and an
# explicitly configured path reach the release executable. The same job
# covers the npm shape, where the entry point is a `.cmd` shim that only
# the command interpreter can run.
run: go test ./internal/daemon -v -run '^(TestCanonicalExecutablePath|TestTrimExtendedLengthPrefix|TestResolveAgentExecutablePathKeeps|TestResolveAgentEntry(FollowsRetargetedInstallerJunction|CanonicalizesRediscoveredJunction|ForLaunchKeepsCmdShimLaunchable|ForLaunchRejectsUnverifiedInitialJunctionTarget|ForLaunchRejectsRediscoveredJunctionWhenFinalPathResolutionFails|DoesNotSharePreRetargetSingleflightResult|ForLaunchFailsWhenJunctionKeepsRetargeting)|TestHandleTaskReportsWindowsCodexProcessStartFailure)' -count=1 -timeout=5m
- name: Build Windows CLI helper entrypoint
working-directory: server
run: go build ./cmd/multica
- name: Test Windows Cursor background ownership
working-directory: server
# Cursor's background shell is captured through a second Job Object, and
# TestCaptureCursorBackgroundProcessRejectsForeignJob is windows-tagged,
# so it builds nowhere else. This used to be its own windows-latest job
# on the same runtime scope as this one; same runner, same package, same
# concern, so it is a step here instead of a second runner per merge.
# Last on purpose: absorbing another job's steps means this job's own
# failure modes now truncate that job's coverage too, and Cursor
# ownership is the newest arrival, not the load-bearing one. The ubuntu
# backend job still covers the untagged and Unix arms.
run: go test -race ./pkg/agent -v -run 'TestCursorBackground|TestCaptureCursorBackground' -count=1 -timeout=5m
# The only macOS runner here, and the only place cursor_background_process_darwin.go
# is ever built: it signals a process group through XNU's per-process unique
# identity so a recycled PID cannot redirect a kill, and the two tests pinning
# that are `//go:build darwin`. Windows keeps its arm as a step in
# windows-execenv, but macOS has no sibling job to join, and this covers one
# integration's background shell rather than the shared launch path every
# agent uses. So the daily full run carries it instead of charging every
# backend merge for a macOS runner; `full` also means the stress counts below
# are now the only finalization pass, replacing the single-count merge variant.
macos-runtime:
needs: changes
if: ${{ needs.changes.outputs.full == 'true' }}
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v5
with:
go-version: "1.26.x"
cache-dependency-path: server/go.sum
- name: Verify Cursor background lifecycle and watchdog races
shell: bash
working-directory: server
# Explicit, verbose selection prevents the Unix regression from silently
# disappearing behind a build tag.
run: |
go test -race ./pkg/agent -v -run 'TestCursorBackground|TestCaptureCursorBackground' -count=1
go test -race ./internal/daemon -v -run 'Test.*Background.*Watchdog|Test.*IdleWatchdog' -count=1
- name: Stress macOS finalization
shell: bash
working-directory: server
run: |
go test ./pkg/agent -run '^TestCursorBackgroundLifecycle/finish$' -count=5
go test -race ./pkg/agent -run '^TestCursorBackgroundLifecycle/finish$' -count=5
- name: Verify cgo-free macOS ownership
shell: bash
working-directory: server
run: CGO_ENABLED=0 go test ./pkg/agent -run '^TestCaptureCursorBackground(KernelRejectsStaleIdentity|DetachedSession|LateDescendant)$' -count=1
image-budget:
# Soft gate against the 21.7MB of raw PNG/JPG that MUL-6352 cleared out:
# a bitmap added or grown past 300KB fails until the PR description says
# why. Pull requests only — a push to main has no description to read,
# and the branch it came from was already checked.
needs: changes
if: ${{ github.event_name == 'pull_request' && needs.changes.outputs.images == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Fetch comparison base
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: git fetch --no-tags --depth=1 origin "$BASE_SHA"
- name: Check image budget
env:
PR_BODY: ${{ github.event.pull_request.body }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: node scripts/check-image-budget.mjs --base "$BASE_SHA"
installer:
# Stub-driven shell tests for scripts/install.sh and scripts/install.ps1.
# Kept off the heavy backend job so installer regressions surface
# independently, and exercised on macOS too because the installer targets
# macOS/Homebrew and `tar` / `sed` / `mktemp` differ between BSD and GNU
# userlands. Windows runs the PowerShell installer's own suite: the two
# installers share one port contract, and neither is covered by the
# frontend job.
needs: changes
if: ${{ needs.changes.outputs.installer == 'true' }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Test shell installers
if: runner.os != 'Windows'
run: bash scripts/install.test.sh
- name: Test PowerShell installer
if: runner.os == 'Windows'
shell: pwsh
run: ./scripts/install.ps1.test.ps1
script-checks:
needs: changes
if: ${{ needs.changes.outputs.scripts == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
- name: Test self-host env derivation
run: bash scripts/selfhost-config.test.sh
- name: Test worktree database cleanup
run: bash scripts/worktree-db.test.sh
- name: Test development environment registry
run: bash scripts/dev-env.test.sh
- name: Test UI performance comparison runner
run: bash scripts/perf-compare.test.sh
- name: Verify reserved-slugs.ts is up to date
# Re-runs the generator and fails on any drift from the
# checked-in TypeScript output. The Go side embeds the JSON
# source directly, so a passing diff here proves both sides
# share one source of truth.
run: |
node scripts/generate-reserved-slugs.mjs
git diff --exit-code -- packages/core/paths/reserved-slugs.ts
- name: Setup Helm
uses: azure/setup-helm@v4
- name: Test Helm chart
run: bash scripts/helm-config.test.sh
- name: Test backend entrypoint signals
run: bash scripts/entrypoint.test.sh
- name: Test build output naming
run: bash scripts/makefile-build.test.sh
frontend-quality:
needs: changes
if: ${{ needs.changes.outputs.quality_only == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Check frontend quality
uses: ./.github/actions/frontend-quality