-
-
Notifications
You must be signed in to change notification settings - Fork 87
1171 lines (1163 loc) · 89.5 KB
/
Copy pathci.yml
File metadata and controls
1171 lines (1163 loc) · 89.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
name: CI
on:
pull_request:
# Explicit list because the default (opened/synchronize/reopened) omits ready_for_review -- once the
# heavy jobs below start skipping draft PRs, marking a PR ready must itself trigger a real CI run, not
# wait for the next push.
types: [opened, synchronize, reopened, ready_for_review]
push:
branches:
- main
permissions:
contents: read
concurrency:
# Keep pull requests in one ref-scoped group so newer commits cancel superseded PR runs.
# Add github.sha for push runs so distinct main commits do not cancel each other's validation.
group: ci-${{ github.ref }}-${{ github.event_name == 'push' && github.sha || 'pr' }}
# NB: keep this a literal boolean. An expression here (cancel-in-progress: ${{ ... }}) made GitHub
# fail the workflow at startup (startup_failure), so `validate` never reported.
cancel-in-progress: true
jobs:
# Detect which areas a PR touches so the heavy jobs can skip when irrelevant.
# On push to main everything runs regardless (keeps the coverage baseline solid).
changes:
name: changes
# Just actions/checkout + git diff --check + dorny/paths-filter + the diff-scoped dependency review --
# no build/test work, and it runs FIRST to decide whether validate-code needs to run at all, so gating
# it behind the same contended self-hosted queue it's meant to protect was circular (#2507).
runs-on: ubuntu-latest
timeout-minutes: 5
# pull-requests: write is solely for dependency-review's comment-summary-in-pr: on-failure below.
permissions:
contents: read
pull-requests: write
outputs:
backend: ${{ steps.filter.outputs.backend }}
backendConfig: ${{ steps.filter.outputs.backendConfig }}
ui: ${{ steps.filter.outputs.ui }}
uiContract: ${{ steps.filter.outputs.uiContract }}
mcp: ${{ steps.filter.outputs.mcp }}
mcpCliHarness: ${{ steps.filter.outputs.mcpCliHarness }}
engine: ${{ steps.filter.outputs.engine }}
discoveryIndex: ${{ steps.filter.outputs.discoveryIndex }}
miner: ${{ steps.filter.outputs.miner }}
minerTestHarness: ${{ steps.filter.outputs.minerTestHarness }}
rees: ${{ steps.filter.outputs.rees }}
controlPlane: ${{ steps.filter.outputs.controlPlane }}
observability: ${{ steps.filter.outputs.observability }}
steps:
# Debounce rapid re-pushes to an open PR: every job below (validate-code, validate-tests)
# has `needs: changes` in its dependency chain, so none of
# them get queued for a runner until this job finishes -- and this job lives in the SAME PR-ref-scoped
# concurrency group as everything else (top of file), so a newer push cancels this job outright while
# it's asleep, well before it ever reaches "Filter changed paths". That means a rapid burst of pushes
# only ever lets the LAST push's `changes` run actually complete and unblock the expensive fan-out --
# every earlier push in the burst pays for one cheap, cancelled `changes` job instead of the full
# ~7-job-slot fan-out starting, queueing, and then getting killed mid-flight. No custom "is this SHA
# still current" check is needed: cancel-in-progress already does that for free the moment a newer
# push lands. Skipped on push (github.event_name == 'push', i.e. a merge to main) -- that's a
# once-per-merge event, not iterative amendment, and main's coverage signal shouldn't be delayed.
- name: Debounce rapid pushes
if: ${{ github.event_name == 'pull_request' }}
run: sleep 45
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
# `git diff --check` with no args compares the working tree against the index -- on this checkout's
# default shallow, single-commit fetch they're always identical immediately after checkout, so this
# unconditionally passed regardless of what the PR's actual diff contained (a false-green: the step
# promised a whitespace check that never fired). Fetch just the one comparison SHA (still shallow,
# cheap) and diff against it directly instead. On push (github.event.before can be the null SHA for
# a branch's first push -- not reachable for `main` itself, but kept defensive) or when the fetch
# target isn't resolvable, skip rather than fail the whole pipeline over a best-effort check.
- name: Check whitespace
run: |
BASE_SHA="${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}"
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
echo "No comparison SHA available (e.g. branch's first push) -- skipping."
exit 0
fi
git fetch --depth=1 origin "$BASE_SHA"
git diff --check "$BASE_SHA" HEAD
- name: Filter changed paths
id: filter
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4
with:
filters: |
backend:
- 'src/**'
- 'test/**'
- 'vitest*.config.ts'
- 'tsconfig*.json'
- 'package.json'
- 'package-lock.json'
- 'scripts/**'
- 'migrations/**'
- '.github/workflows/**'
# .github/actions/** (composite actions, e.g. setup-workspace) is workflow-adjacent
# infrastructure the same way .github/workflows/** above is -- without this, a PR editing
# ONLY a composite action file (touching nothing else backend matches) would skip both
# "Lint workflows" and "Lint composite actions" below entirely, with zero CI signal on a
# broken edit until the next push-triggered full run.
- '.github/actions/**'
- 'wrangler.jsonc'
- 'worker-configuration.d.ts'
- '.loopover.yml'
- '.nvmrc'
- '.release-please-manifest.json'
- 'release-please-config.json'
# The subset of `backend` above that ISN'T plain src/**+test/** TypeScript -- config, migrations,
# and workflow/tooling files with no import-graph edge a test file could ever have to them. Exists
# solely to gate scoped test selection (#ci-scoped-test-selection, validate-tests below): that
# verification covered only plain .ts source-file diffs replayed against real historical commits,
# never a migration or a package.json/tsconfig/workflow change, and `--changed`'s dependency
# tracing has no mechanism to connect a non-JS/TS file to the tests that would need to catch a
# regression in it (a migration's coverage comes from a migration-runner test that reads the
# migrations/ directory at runtime, not from a static `import` of the changed .sql file). A PR
# matching this stays on the full unscoped suite even if it also touches src/**.
backendConfig:
- 'vitest*.config.ts'
- 'tsconfig*.json'
- 'package.json'
- 'package-lock.json'
- 'scripts/**'
- 'migrations/**'
- '.github/workflows/**'
- '.github/actions/**'
- 'wrangler.jsonc'
- 'worker-configuration.d.ts'
- '.loopover.yml'
- '.nvmrc'
- '.release-please-manifest.json'
- 'release-please-config.json'
observability:
- 'grafana/dashboards/**'
- 'prometheus/rules/**'
# The UI's own app code -- triggers the FULL toolchain (lint/typecheck/test/build).
# A dependency bump (package.json/package-lock.json) stays here too since it can break the UI
# build or types in ways only that full toolchain would catch.
ui:
- 'apps/loopover-ui/**'
- 'apps/loopover-miner-ui/**'
- 'packages/loopover-ui-kit/**'
- 'package.json'
- 'package-lock.json'
# Backend changes that can drift the OpenAPI contract the UI type-checks against, but say
# nothing about the UI app's OWN code -- only the lightweight drift check needs to run, not
# ui:lint/typecheck/test/build (#ci-scope). If a backend PR's change IS UI-relevant, regenerating
# `apps/loopover-ui/public/openapi.json` per CONTRIBUTING.md lands a change under `ui` above,
# which correctly re-triggers the full toolchain.
uiContract:
- 'src/**'
- 'scripts/write-ui-openapi.ts'
mcp:
# Self-contained package: build is `node --check` on its own files
# and the pack check only inspects the tarball. Root src/ cannot
# affect it, so it is intentionally NOT a trigger here.
- 'packages/loopover-mcp/**'
- 'scripts/check-mcp-package.ts'
- 'package-lock.json'
engine:
- 'packages/loopover-engine/**'
- 'package-lock.json'
miner:
- 'packages/loopover-miner/**'
- 'scripts/check-miner-package.ts'
- 'package-lock.json'
# No dedicated build/pack script (unlike mcp/engine/miner above) -- discovery-index is a normal
# vitest-covered workspace package (packages/discovery-index/src/**/*.ts is in vitest.config.ts's
# coverage.include), so this filter only needs to feed validate-code's overall gate + validate-tests
# below, not a standalone job.
discoveryIndex:
- 'packages/discovery-index/**'
- 'package-lock.json'
# 6 of the 7 MCP CLI-cluster test files, and ONLY these 6, are truly self-contained w.r.t. root
# src/**: verified by direct-import inspection that test/unit/mcp-cli-*.test.ts (5 files) and
# their shared test/unit/support/mcp-cli-harness.ts import nothing but node:* builtins + vitest,
# and mcp-discovery.test.ts spawns packages/loopover-mcp/bin/loopover-mcp.ts (gitignored
# compiled output; Node's own type-stripping runs the .ts directly, no build needed) as a real
# subprocess via StdioClientTransport -- none of the 6 ever load root src/ in-process. This mirrors
# the `mcp` filter's own established trust boundary above (a self-contained package build). They are
# NOT self-contained w.r.t. packages/loopover-engine/**, though: loopover-mcp now has a real
# dependency on @loopover/engine (isTestFile/isCodeFile), which the bin subprocess and
# local-branch.js load at runtime -- hence that path below, alongside the mcp package's own.
# test/unit/mcp-output-schemas.test.ts is DELIBERATELY NOT in this filter: unlike the other 6, it
# imports src/mcp/server.ts in-process, and server.ts alone directly imports ~40 other src/ modules
# (src/github/app.ts, src/signals/slop.ts, src/settings/autonomy.ts, src/orb/analytics.ts, and
# most of src/services/** + src/signals/**), so its real dependency surface is practically all of
# `backend`. An earlier version of this filter hand-picked a handful of "the src files that affect
# it" for that file too -- that guess missed dozens of server.ts's actual direct imports, which was
# a reachable CI blind spot (a backend change outside the guessed list could regress MCP server
# behavior with zero test rerun to catch it). Enumerating that surface by hand is not safely
# possible, so mcp-output-schemas.test.ts is simply never excluded -- it always runs whenever
# `backend` does, at the cost of keeping its slice of the suite's runtime unconditional.
mcpCliHarness:
- 'vitest*.config.ts'
- 'tsconfig*.json'
- 'package.json'
- 'package-lock.json'
- 'scripts/**'
- 'migrations/**'
- '.github/workflows/**'
- 'packages/loopover-mcp/**'
- 'packages/loopover-engine/**'
- 'test/helpers/**'
- 'test/unit/mcp-cli-*.test.ts'
- 'test/unit/mcp-discovery.test.ts'
- 'test/unit/support/mcp-cli-harness.ts'
rees:
- 'review-enrichment/**'
- '.github/workflows/ci.yml'
controlPlane:
- 'control-plane/**'
- '.github/workflows/ci.yml'
# Both miner-package test files are self-contained w.r.t. root src/**, the same trust boundary as
# mcpCliHarness above: check-miner-package.test.ts only spawns scripts/check-miner-package.ts as a
# real subprocess (node:child_process + vitest, nothing else), and miner-calibration-types.test.ts
# only imports the scaffolded packages/loopover-miner/lib/calibration.ts -- neither ever loads
# root src/ in-process. Mirrors mcpCliHarness's filter shape for the same reason (tooling/config
# changes that could plausibly affect how these tests run, plus the miner package + the test files
# themselves), swapping in the miner-specific package and test files.
minerTestHarness:
- 'vitest*.config.ts'
- 'tsconfig*.json'
- 'package.json'
- 'package-lock.json'
- 'scripts/**'
- 'migrations/**'
- '.github/workflows/**'
- 'packages/loopover-miner/**'
- 'test/unit/check-miner-package.test.ts'
- 'test/unit/miner-calibration-types.test.ts'
# Diff-scoped security gate, folded in from the old standalone `security` job (2026-07-24, one fewer
# runner slot on every PR at zero added wall-clock -- the API-based lockfile diff takes seconds and
# needs nothing this job hasn't already got). Fails only on vulnerabilities this PR introduces;
# ambient advisories in untouched deps are handled by Renovate + the scheduled audit workflow, so one
# upstream CVE never blocks unrelated PRs. A failure here fails `changes`, which skips the whole
# fan-out and reddens `validate` -- the same end state the standalone job produced, one job earlier.
- name: Dependency review
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.draft != true }}
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
fail-on-severity: moderate
comment-summary-in-pr: on-failure
# Path-aware validation. Keep this as one job so a PR uses one dependency
# install for the fast checks (drift/typecheck/build/UI toolchain). The
# expensive full-suite coverage run is sharded out to validate-tests below
# (#ci-shard-coverage) -- it dominated wall-clock time (~9-10 of ~11 minutes
# on a typical backend PR) and doesn't benefit from sharing an install with
# these much-faster steps. Run on GitHub-hosted runners while the
# self-hosted review stack is CPU constrained.
validate-code:
name: validate-code
needs: changes
# draft != true also gates push runs correctly: github.event.pull_request is unset there, so the
# property access evaluates to null, and null != true is true.
if: ${{ github.event_name == 'push' || (github.event.pull_request.draft != true && (needs.changes.outputs.backend == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.discoveryIndex == 'true' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.rees == 'true' || needs.changes.outputs.controlPlane == 'true' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.observability == 'true')) }}
runs-on: ubuntu-latest
timeout-minutes: 45
env:
VITE_LOOPOVER_API_ORIGIN: https://api.loopover.ai
steps:
# Shallow (default depth): this job no longer uploads to Codecov -- that moved to validate-tests
# (#ci-shard-coverage) -- so it has no reason to fetch full history anymore.
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
# See .github/actions/setup-workspace for what this covers (npmrc, Node, node_modules cache) --
# the checkout above stays a separate, explicit step since a local `uses: ./path` action needs
# the repo already on disk to even find its own action.yml.
- name: Setup workspace
uses: ./.github/actions/setup-workspace
# apps/loopover-ui/.source (fumadocs-mdx codegen) is regenerated by "Generate docs content
# collections" below, gated on push||ui==true -- nothing between here and there reads it (docs-drift,
# env-reference, manifest/engine-parity/branding-drift checks all read raw source files directly, and
# the root Typecheck step's tsconfig.json excludes apps/loopover-ui entirely), so it doesn't need to
# be regenerated unconditionally this early too (a prior version of this step duplicated that one,
# added independently and unconditionally before either author noticed the other already existed).
- name: Lint workflows
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
run: npm run actionlint
# actionlint (above) does not cover .github/actions/**/action.yml at all -- confirmed a genuine,
# long-standing upstream limitation (rhysd/actionlint#46 and #401, open since 2021), not something
# fixable by a different invocation: even the raw actionlint binary, run directly with no wrapper,
# treats any file it's given as a workflow and errors on runs/inputs/outputs as unexpected
# top-level keys. This validates composite action files against GitHub's own official
# action-metadata JSON Schema instead -- real structural validation, not the full expression-
# context linting actionlint does for workflows, which genuinely doesn't exist anywhere for
# action.yml files.
- name: Lint composite actions
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
run: npm run lint:composite-actions
- name: Check migrations
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
run: npm run db:migrations:check
# Guards against a DIFFERENT drift class than db:migrations:check's own column-collision check (#2551,
# which only catches two migration FILES independently adding the same column): src/db/schema.ts's
# DECLARED shape silently drifting from what migrations/ ACTUALLY produces when replayed (#2565).
- name: Schema-vs-migrations drift check
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
run: npm run db:schema-drift:check
# Guards against the same staleness-drift class as db:migrations:check/ui:openapi:check: two PRs that
# each independently add a wrangler.jsonc binding can both pass CI in isolation, then merge sequentially
# and leave a stale committed worker-configuration.d.ts with zero prior gate signal (#2557).
- name: cf-typegen drift check
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
run: npm run cf-typegen:check
# Same drift class as cf-typegen/schema-drift above, just a different generated artifact: this file's
# firstReference line numbers point into src/selfhost/ai.ts, so any backend change that shifts those
# lines (not just one that adds/removes an env var) can make it stale. Was previously enforced ONLY by
# the local `npm run test:ci` aggregate script, never by this workflow -- went stale twice in one day
# (2026-07-04) with zero CI signal until someone happened to run the full local script. Gated on `ui`
# too, not just `backend`: the generated file itself lives under apps/loopover-ui/**, so a PR that
# hand-edits it (or a docs page docs:drift-check below cross-checks) without touching backend source
# would otherwise never re-run this check.
- name: Selfhost env-reference drift check
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.ui == 'true' }}
run: npm run selfhost:env-reference:check
# Miner/AMS twin of the selfhost check above -- same generated-artifact-drift class, same
# local-only-until-now gap (an `env.SOMETHING` read added under packages/loopover-miner/**
# with no CI signal that packages/loopover-miner/docs/env-reference.md and
# apps/loopover-ui/src/lib/ams-env-reference.ts had gone stale). Confirmed harmful live: this
# exact gap let a stale env-reference block an unrelated MCP npm release, since the release
# workflow was the only place this check ever ran. Gated on `ui` too, same reason as its sibling:
# the generated UI-side file lives under apps/loopover-ui/**. Also gated on `engine`:
# DEFAULT_SOURCE_ROOTS in generate-env-reference.mjs explicitly includes
# packages/loopover-engine/src/miner (the coding-agent driver's env vars live there, not under
# packages/loopover-miner itself), so an engine-only PR touching that directory used to skip this
# check entirely.
- name: Miner env-reference drift check
if: ${{ github.event_name == 'push' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.engine == 'true' }}
run: npm run miner:env-reference:check
# Same generated-artifact-drift class, extracted from src/github/commands.ts's command catalogs (#3046).
# Also local-only until now; same backend-or-ui gating rationale as the step above.
- name: Command reference drift check
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.ui == 'true' }}
run: npm run command-reference:check
# Cross-checks docs pages against source-of-truth enumerable surfaces (feature flags, commands, gate
# modes) -- see the script's own header comment. Also local-only until now; gated on `ui` as well as
# `backend` since the docs pages it validates are themselves UI-side content that can drift on their
# own (a docs-only edit that renames/drops a documented flag/command/gate-mode) with no backend change
# to trigger a re-check otherwise. Also gated on `engine`, unlike its two siblings above (`Selfhost
# env-reference drift check`/`Command reference drift check`): this script's readFileSync reads
# packages/loopover-engine/src/focus-manifest.ts directly to cross-check FocusManifest fields against
# .loopover.yml.example, a dependency the other two generated-artifact-drift checks don't share, so
# deliberately NOT kept uniform with them here (test/unit/ci-generated-artifact-drift-checks.test.ts
# asserts backend||ui is a SUBSET of every condition, not that all three are byte-identical).
- name: Docs drift check
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.engine == 'true' }}
run: npm run docs:drift-check
# Cross-checks the bundled fallback YAML in src/config/loopover-repo-focus-manifest.ts against the
# real root .loopover.yml -- see the script's own header comment. Same local-only-until-now gap as
# the drift checks above: nothing in this workflow previously ran it, so the two could silently
# diverge with zero CI signal. .loopover.yml itself is now part of the `backend` path filter above
# specifically so an edit to ONLY that file still re-triggers this job.
- name: Manifest drift check
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
run: npm run manifest:drift-check
# Mechanical drift tripwire for the hand-duplicated src/{review,settings,signals} <-> loopover-engine
# twin files, plus a version-skew check on the installed @loopover/engine (#4260). Same
# local-only-until-now gap as the drift checks above. Also gated on `miner`: the reverse-direction
# version pin this script checks, packages/loopover-miner/expected-engine.version, lives under the
# miner package, not engine or backend -- a PR editing only that one pin file used to skip its own
# consistency check entirely.
- name: Engine-parity drift check
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.miner == 'true' }}
run: npm run engine-parity:drift-check
# Guards against the "gittensory" branding creeping back into runtime source after the LoopOver
# rebrand -- see the script's own header comment (#6786 is the concrete incident this was written
# for). Scoped broadly (src/** plus every workspace package's bin/lib/src/scripts dirs), so it's
# gated broadly to match; same local-only-until-now gap as the drift checks above. Also gated on
# `discoveryIndex`: BRANDING_DRIFT_PATHSPECS includes "packages/*/src/**/*.ts", which matches
# packages/discovery-index/src/**.
- name: Branding drift check
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.discoveryIndex == 'true' }}
run: npm run branding-drift:check
# Every engines.node in this repo used to be open-ended (">=22.0.0", no upper bound), so npm never
# warned or blocked a Node 23+ install locally -- exactly how the Node 26 jsdom/localStorage gap
# (nodejs/node#60303) went undetected in apps/loopover-miner-ui (#7592/#7597), then again in
# apps/loopover-ui + packages/loopover-ui-kit, with zero CI signal either time (CI itself was never
# at risk: setup-workspace pins node-version-file: .nvmrc). This is the same local-only-until-now gap
# as the drift checks above. Gated the same broad way as branding-drift-check: every workspace group
# that can declare its own engines.node (mcp/engine/miner/ui/discoveryIndex) plus backend, which now
# also covers a `.nvmrc`-only bump.
- name: Engines/.nvmrc sync check
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.discoveryIndex == 'true' }}
run: npm run engines-nvmrc:check
# .release-please-manifest.json is release-please's own state file -- it normally keeps this in
# sync itself when its generated Release PR merges, but the documented human-override path for
# a stuck release tag (publish-mcp.yml's own header comment) bumps package.json directly with
# no way to touch release-please's state. That gap went uncaught with zero CI signal, then
# surfaced days later as release-please re-proposing an already-published version as a brand-new
# Release PR, which fails on publish (#7086/#7087). `.release-please-manifest.json` and
# `release-please-config.json` are now part of the `backend` path filter above so an edit to
# either alone still re-triggers this job.
- name: Release-please manifest sync check
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.ui == 'true' }}
run: npm run release-manifest:sync:check
# Not gated on `backend`: read the full script -- it only ever reads grafana/dashboards/*.json and
# prometheus/rules/alerts.yml, both fully covered by the `observability` filter above, with zero
# reference to any src/, test/, or other backend path. The `backend` clause had no structural
# justification and made this run on every backend PR (the highest-volume PR shape in the repo) for
# nothing.
- name: Validate observability configs
if: ${{ github.event_name == 'push' || needs.changes.outputs.observability == 'true' }}
env:
NODE_OPTIONS: ""
run: node --experimental-strip-types scripts/validate-observability-configs.ts
# Runs ahead of Typecheck AND "Test with coverage" (#ci-engine-build-order): src/mcp/find-opportunities.ts
# (root backend, since #2281/#3985) imports packages/loopover-miner/lib/opportunity-fanout.js --
# a .js-suffixed specifier esbuild/Vite resolve straight to the real (gitignored, uncompiled)
# .ts source, same as @loopover/engine's own src/**, so THIS half needs no build at all -- which
# itself imports @loopover/engine THE PACKAGE, though, via a bare `@loopover/engine` specifier
# elsewhere in the chain, and THAT resolves through node_modules to dist/. That package's dist/ is
# gitignored and only exists after this build step, so ANY backend typecheck or test run needs it built
# first, not just a PR that touches packages/loopover-engine/** directly (this step's original, too-
# narrow trigger). Originally ran after coverage (broke test:coverage on PRs that didn't touch the
# engine) and after Typecheck (broke a real, non-vi.mock `import type` from the package specifier with
# TS2307 in CI while passing locally against a stale leftover dist/ -- first hit by PR #5082). Now ahead
# of both.
#
# Turborepo (turbo.json) owns the actual cache correctness here (content-hashes @loopover/engine's own
# inputs independently of whatever this restore serves) -- the .turbo/cache restore below is purely an
# accelerator, same accumulating-cache shape as "Restore TypeScript incremental build cache" below (a
# run_id-suffixed save key + prefix restore-keys fallback, not the hit-or-miss node_modules pattern),
# since turbo's cache directory grows additively rather than being wholesale-replaced per lockfile hash.
# This is also what lets each validate-tests shard (which independently needs this same package built)
# skip rebuilding it from scratch when a warm cache from this job or an earlier run is available.
#
# Gate includes mcp/miner (not just backend/engine/ui) even though "Build engine package" below stays
# scoped to backend/engine/ui: "Build MCP"/"Build miner CLI" further down invoke turbo too, and since
# both packages declare @loopover/engine as a package.json dependency, turbo's own ^build resolution
# builds engine as their transitive dependency regardless of whether this explicit step ran. Without
# this wider gate, an mcp- or miner-only PR would still get a correct build (turbo builds it inline
# with no cache to hit) -- just a cold one, and with no save step to persist it either.
#
# The matching "Save Turborepo cache" step is NOT alongside this one -- it's moved all the way down
# to after "UI build" (the last step in this job that invokes turbo), since actions/cache/save takes
# one synchronous snapshot at the moment it runs and every later turbo-routed step in between (Build
# MCP, Build miner CLI, Build UI-kit package, UI lint/typecheck, UI build)
# would otherwise write to .turbo/cache AFTER the snapshot already happened, so none of their cache
# entries would ever persist cross-run. Confirmed this was a real gap (not just theoretical) by
# reading the actual step order before moving it.
#
# Key uses a "turbo-code-" prefix, distinct from validate-tests' "turbo-tests-" prefix below, even
# though both jobs cache the same .turbo/cache path and both build @loopover/engine -- they used to
# share one "turbo-" prefix, which meant all 7 jobs in a single run (this one plus 6 validate-tests
# shards) raced for the SAME cache key. actions/cache/save no-ops on a duplicate key within a run (see
# the comment on validate-tests' own Save step below), and a validate-tests shard reaches its Save
# step after only ~6 steps versus this job's ~20+, so a shard almost always won the race -- meaning
# the richer cache this job builds (engine + mcp + miner + ui-kit + ui) was very likely
# silently discarded in favor of a shard's much leaner engine-only save, defeating most of the point
# of the Save-repositioning fix above. Splitting the prefixes gives each job's Restore/Save pair its
# own independent lineage instead of racing.
- name: Restore Turborepo cache
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.miner == 'true' }}
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .turbo/cache
key: turbo-code-${{ hashFiles('package-lock.json') }}-${{ github.run_id }}
restore-keys: |
turbo-code-${{ hashFiles('package-lock.json') }}-
- name: Build engine package
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.ui == 'true' }}
run: npx turbo run build --filter=@loopover/engine
# Mirrors "MCP package check"/"Miner package check" below: the published npm tarball is a
# different surface than the workspace build above (files field, forbidden paths/content,
# stale README wording) and needs its own dry-run validation (#8591).
- name: Engine package check
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.ui == 'true' }}
run: npm run test:engine-pack
# .tsbuildinfo mutates every run (tsc's own incremental state), unlike node_modules above which is
# immutable per lockfile -- so this needs the run_id-suffixed-key + restore-keys-prefix pattern (always
# creates a new cache entry to save into, restore falls back to the most recent matching prefix) rather
# than the hit-or-miss pattern node_modules uses. Measured ~13.5s cold vs ~3.6s warm locally; tsc
# verifies each file's content hash before trusting cached state, so a fresh checkout's reset mtimes
# can't cause a false cache hit that would mask a real type error.
- name: Restore TypeScript incremental build cache
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .tsbuildinfo
key: tsbuildinfo-${{ hashFiles('tsconfig.json', 'package-lock.json') }}-${{ github.run_id }}
restore-keys: |
tsbuildinfo-${{ hashFiles('tsconfig.json', 'package-lock.json') }}-
# Deliberately NOT routed through Turborepo's //#typecheck task, unlike every other build/lint/
# typecheck step in this job -- investigated and rejected, not just skipped. test/** (tsconfig.json's
# own "include") reaches via relative imports into six other workspace packages/apps (see turbo.json's
# //#typecheck comment for the full audit), so a turbo.json inputs list correct enough to avoid a
# stale-cache-masks-a-real-error bug here would need to track that sprawling, growing cross-package
# surface by hand. tsc's own --incremental engine (below) already tracks the exact real dependency
# graph natively and precisely, with zero drift risk, and already delivers the actual measured
# speedup (~13.5s cold vs ~3.6s warm) -- wrapping it in turbo would layer a cruder, hand-maintained
# approximation on top of a mechanism that's already strictly more correct for this specific task.
# Gated on engine/mcp/miner too, not just backend: root src/** genuinely imports across those three
# package boundaries (~57 files import @loopover/engine directly; src/mcp/find-opportunities.ts
# imports packages/loopover-miner/lib/opportunity-fanout.js and opportunity-ranker.js;
# src/services/mcp-compatibility.ts imports packages/loopover-mcp/package.json), so a type-only
# regression introduced in any of those three packages, on a PR that touches only that package, used
# to skip this check entirely -- not caught until the push-triggered run on main, after this repo's
# auto-merge gate had already merged it. tsc's own --incremental engine (unaffected by this widening;
# see its own comment two steps up) already tracks the real graph regardless of which trigger fired.
- name: Typecheck
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.miner == 'true' }}
run: npm run typecheck
- name: Save TypeScript incremental build cache
if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true') }}
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .tsbuildinfo
key: tsbuildinfo-${{ hashFiles('tsconfig.json', 'package-lock.json') }}-${{ github.run_id }}
# Same engine/mcp/miner gap as Typecheck above, and more consequential here: test/workers/worker-
# runtime.test.ts (vitest.config.ts excludes it from the main "Test with coverage" run below, so
# nothing else in CI runs it) imports src/index and src/api/routes, which themselves import
# find-opportunities.ts (miner) and mcp-compatibility.ts (mcp) -- this is the only test in all of CI
# that boots the real Cloudflare Workers runtime end-to-end, and it used to never run at all on an
# engine/mcp/miner-only PR.
- name: Worker runtime tests
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.miner == 'true' }}
run: npm run test:workers
# loopover-mcp now depends on @loopover/engine for real (isTestFile/isCodeFile), so a
# PR that only touches packages/loopover-engine/** must also rebuild + pack-check the mcp package,
# not just the mcp filter's own (deliberately narrower) path list.
#
# Routed through Turborepo (turbo.json's @loopover/mcp#build, dependsOn ["^build"]) rather than the
# old npm run build:mcp -- turbo resolves @loopover/engine as its topological dependency (mcp's own
# package.json depends on @loopover/engine) and reuses the "Restore Turborepo cache" above, so an
# engine-only PR's already-built engine is never rebuilt a second time here.
# packages/loopover-mcp ships .ts source only -- no compiled .js/.d.ts is committed (gitignored;
# see vitest.config.ts's resolveMinerMcpTsSourcePlugin for how tests run against the .ts directly
# with no build step). This "Build MCP" step still exists because the PACKAGE ITSELF has to ship
# runnable .js in its published npm tarball (npm install -g gives you plain Node, no TS toolchain),
# and "MCP package check" right below needs that real build output on disk to validate.
- name: Build MCP
if: ${{ github.event_name == 'push' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' }}
run: npx turbo run build --filter=@loopover/mcp
- name: MCP package check
if: ${{ github.event_name == 'push' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' }}
run: npm run test:mcp-pack
# Same "ships .ts only, this build is for the published npm tarball + the pack-check below, not for
# tests" story as MCP's own "Build MCP" step above. Invokes turbo.json's @loopover/miner#build:tsc
# (real caching win since the 2026-07-24 dist/ migration -- previously cache: false, historically to
# avoid a cache restore stomping this package's hand-written .js files while the #7290 migration was
# still in-flight; #7317 closed that out, and the dist/ split removed the original in-place-emit
# hazard entirely, so caching was finally enabled) and @loopover/miner#build:verify (real caching
# win: skips re-`node --check`-ing all bin/lib files when neither changed) DIRECTLY as their own task names,
# deliberately NOT via the aggregate `@loopover/miner#build` task (which exists for standalone/local
# `npm run build` callers) -- that aggregate's own script is `npm run build:tsc && npm run
# build:verify`, i.e. it re-invokes both of these exact scripts a second time even when turbo already
# ran them as cached prerequisite tasks (declaring a task as a dependsOn prerequisite doesn't replace
# a parent task's own script body). Calling the aggregate here would silently double both the tsc
# compile and the syntax check on every run, negating the caching win this comment describes --
# confirmed by reproducing the double execution locally before switching to this direct form. This
# also finally activates the build:tsc/build:verify split from Phase 0, which no ci.yml step
# exercised until now.
- name: Build miner CLI
if: ${{ github.event_name == 'push' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.engine == 'true' }}
run: npx turbo run build:tsc build:verify --filter=@loopover/miner
# loopover-miner depends on @loopover/engine for real too (same relationship as loopover-mcp above
# -- packages/loopover-miner/package.json lists it as a dependency), so this and "Build miner CLI"
# above now also trigger on an engine-only PR, matching "Build MCP"/"MCP package check"'s existing
# engine coupling. Pre-existing asymmetry, not introduced by this change -- fixed here while already
# touching these exact lines.
- name: Miner package check
if: ${{ github.event_name == 'push' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.engine == 'true' }}
run: npm run test:miner-pack
# scripts/check-miner-deployment-docs.ts reads packages/loopover-engine/src/miner/**/*.ts
# (ENGINE_MINER_DIR) to build the registered-commands list checked against DEPLOYMENT.md, so an
# engine-only PR touching that directory used to skip this audit entirely.
- name: Miner deployment docs audit
if: ${{ github.event_name == 'push' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.engine == 'true' }}
run: npm run test:miner-deployment-docs-audit
# review-enrichment is not an npm workspace member (its own package-lock.json), so it needs its own
# cache entry -- same restore/save-after-success pattern and fork/trusted key split as the root
# install above, for the same reasons.
- name: Restore review-enrichment node_modules cache
id: rees-node-modules-cache
if: ${{ github.event_name == 'push' || needs.changes.outputs.rees == 'true' }}
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: review-enrichment/node_modules
# Same Node-version and manifest guards as the root cache key above -- REES runs on the same
# pinned .nvmrc and has its own package.json lifecycle/install validation.
key: npm-rees-${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && 'fork' || 'trusted' }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('review-enrichment/package.json', 'review-enrichment/package-lock.json') }}
- name: REES install
if: ${{ (github.event_name == 'push' || needs.changes.outputs.rees == 'true') && steps.rees-node-modules-cache.outputs.cache-hit != 'true' }}
run: npm run rees:install
- name: Save review-enrichment node_modules cache
if: ${{ (github.event_name == 'push' || needs.changes.outputs.rees == 'true') && steps.rees-node-modules-cache.outputs.cache-hit != 'true' }}
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: review-enrichment/node_modules
key: ${{ steps.rees-node-modules-cache.outputs.cache-primary-key }}
- name: REES build, source-map validation, and tests
if: ${{ github.event_name == 'push' || needs.changes.outputs.rees == 'true' }}
run: npm --prefix review-enrichment test
# review-enrichment's node:test suite runs against the built dist/, so its real coverage was invisible
# to Codecov (which only ever saw vitest's src/** lcov). `npm run rees:coverage` re-runs that suite under
# c8 (v8 coverage) and remaps the dist hits back to review-enrichment/src/** through the build's source
# maps, producing an lcov Codecov can gate. The authoritative pass/fail for REES stays the uninstrumented
# step above: c8's instrumentation inflates that suite's timing-sensitive linear-time/ReDoS-guard
# assertions past their (uninstrumented) budgets, so a non-zero child exit here is expected -- this
# harvest only needs to emit lcov, and a genuinely broken suite is already caught above (and would show
# as ~0% patch anyway). The verify step below fails CI if no report lands.
- name: REES coverage
if: ${{ github.event_name == 'push' || needs.changes.outputs.rees == 'true' }}
run: npm run rees:coverage || true
- name: Verify REES coverage report exists
if: ${{ github.event_name == 'push' || needs.changes.outputs.rees == 'true' }}
run: |
if [ ! -s review-enrichment/coverage/lcov.info ]; then
echo "::error title=REES coverage::review-enrichment/coverage/lcov.info is missing or empty"
exit 1
fi
# Separate `rees` flag so codecov/patch actually gates review-enrichment/** changes -- before this the
# package was in no coverage report at all, so a REES-only PR got a vacuous patch check. Same trusted vs.
# fork-tokenless split and override_* handling as the shard uploads in validate-tests below.
- name: Upload REES coverage to Codecov
if: ${{ (github.event_name == 'push' || needs.changes.outputs.rees == 'true') && github.event.pull_request.head.repo.fork != true }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./review-enrichment/coverage/lcov.info
flags: rees
disable_search: true
override_branch: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }}
override_commit: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
override_pr: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || '' }}
fail_ci_if_error: true
- name: Upload REES coverage to Codecov (fork PR tokenless)
if: ${{ (github.event_name == 'push' || needs.changes.outputs.rees == 'true') && github.event.pull_request.head.repo.fork == true }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: ./review-enrichment/coverage/lcov.info
flags: rees
disable_search: true
override_branch: ${{ github.event.pull_request.head.repo.owner.login }}:${{ github.event.pull_request.head.ref }}
override_commit: ${{ github.event.pull_request.head.sha }}
override_pr: ${{ github.event.pull_request.number }}
fail_ci_if_error: true
# control-plane is not an npm workspace member (its own package-lock.json), so it needs its own
# cache entry -- same restore/save-after-success pattern and fork/trusted key split as REES above.
- name: Restore control-plane node_modules cache
id: control-plane-node-modules-cache
if: ${{ github.event_name == 'push' || needs.changes.outputs.controlPlane == 'true' }}
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: control-plane/node_modules
key: npm-control-plane-${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && 'fork' || 'trusted' }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('control-plane/package.json', 'control-plane/package-lock.json') }}
- name: Control-plane install
if: ${{ (github.event_name == 'push' || needs.changes.outputs.controlPlane == 'true') && steps.control-plane-node-modules-cache.outputs.cache-hit != 'true' }}
run: npm run control-plane:install
- name: Save control-plane node_modules cache
if: ${{ (github.event_name == 'push' || needs.changes.outputs.controlPlane == 'true') && steps.control-plane-node-modules-cache.outputs.cache-hit != 'true' }}
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: control-plane/node_modules
key: ${{ steps.control-plane-node-modules-cache.outputs.cache-primary-key }}
- name: Control-plane build and tests
if: ${{ github.event_name == 'push' || needs.changes.outputs.controlPlane == 'true' }}
run: npm run control-plane:test
- name: Control-plane coverage
if: ${{ github.event_name == 'push' || needs.changes.outputs.controlPlane == 'true' }}
run: npm run control-plane:coverage
- name: Verify control-plane coverage report exists
if: ${{ github.event_name == 'push' || needs.changes.outputs.controlPlane == 'true' }}
run: |
if [ ! -s control-plane/coverage/lcov.info ]; then
echo "::error title=Control-plane coverage::control-plane/coverage/lcov.info is missing or empty"
exit 1
fi
- name: Upload control-plane coverage to Codecov
if: ${{ (github.event_name == 'push' || needs.changes.outputs.controlPlane == 'true') && github.event.pull_request.head.repo.fork != true }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./control-plane/coverage/lcov.info
flags: control-plane
disable_search: true
override_branch: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }}
override_commit: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
override_pr: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || '' }}
fail_ci_if_error: true
- name: Upload control-plane coverage to Codecov (fork PR tokenless)
if: ${{ (github.event_name == 'push' || needs.changes.outputs.controlPlane == 'true') && github.event.pull_request.head.repo.fork == true }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: ./control-plane/coverage/lcov.info
flags: control-plane
disable_search: true
override_branch: ${{ github.event.pull_request.head.repo.owner.login }}:${{ github.event.pull_request.head.ref }}
override_commit: ${{ github.event.pull_request.head.sha }}
override_pr: ${{ github.event.pull_request.number }}
fail_ci_if_error: true
- name: OpenAPI drift check
if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.uiContract == 'true' }}
run: npm run ui:openapi:check
# #2556: RepositorySettingsSchema (hand-authored Zod) can silently drift from the actual
# RepositorySettings TS type -- the OpenAPI drift check above only verifies the generated spec
# matches the Zod schema, never that the schema matches the type the API actually serializes.
- name: OpenAPI settings-parity check
if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.uiContract == 'true' }}
run: npm run ui:openapi:settings-parity
# Checks apps/loopover-ui/src' known-latest MCP version string against the published package, so
# its dependency is `ui` (the file it scans) + `mcp` (the package it checks against) -- NOT the
# OpenAPI contract, which this script never reads.
- name: UI/MCP version audit
if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.mcp == 'true' }}
run: npm run ui:version-audit
# loopover-ui and loopover-miner-ui both import compiled output from this shared package
# (packages/loopover-ui-kit/dist), which is gitignored like every other workspace package's
# dist -- so it does not exist on a fresh checkout until something builds it. Same class of gap
# as "Build engine package" above; runs before UI lint/typecheck/tests/build all in this same
# job, so building it here once covers all four.
#
# `!cancelled()` on this step and every UI step below it (through "UI tests (ui)"): this job runs
# a long, unrelated chain of drift checks before reaching here (migrations, schema, env-reference,
# docs, manifest, engine-parity, branding, .nvmrc, release-manifest, observability, the MCP
# version audit just above, ...) -- any ONE of those failing without continue-on-error otherwise
# skips every step after it by GitHub Actions' own default, INCLUDING this build. "UI tests
# (ui-miner)" already had this guard (see its own comment below);
# this build step and everything through "UI tests (ui)" didn't, so a same-day MCP release
# tripping the version-audit step above skipped ui-kit's build entirely, and ui-miner's tests
# (which DID still run, guarded) then failed with a confusing "Failed to resolve import
# '@loopover/ui-kit/components/...'" that looked like a real code bug instead of a skipped
# upstream build -- confirmed live, PR #8182. The job as a whole still fails if any step fails;
# this only stops one unrelated failure from hiding/masking every UI result behind it.
- name: Build UI-kit package
if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.ui == 'true') }}
run: npx turbo run build --filter=@loopover/ui-kit
# Mirrors "MCP package check"/"Miner package check" above: the published npm tarball is a
# different surface than the workspace build above and needs its own dry-run validation (#8591).
- name: UI-kit package check
if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.ui == 'true') }}
run: npm run test:ui-kit-pack
# apps/loopover-ui's fumadocs-mdx virtual modules (collections/browser, collections/server, imported by
# docs-client-loader.tsx/docs-source.ts) are gitignored generated output, normally produced by npm ci's
# own `postinstall` hook -- but "Install dependencies" above is SKIPPED on a node_modules cache hit (the
# cached path never includes this output), so a warm-cache run reaches UI lint/typecheck/tests/build with
# no collections output at all and fails with "Cannot find module 'collections/browser'". Same class of
# gap as "Build UI-kit package" above (a required, gitignored artifact absent on a fresh/cached
# checkout); runs unconditionally here so cache hit or miss makes no difference.
- name: Generate docs content collections
if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.ui == 'true') }}
run: npm run postinstall --workspace @loopover/ui
# Routed through Turborepo -- "Build UI-kit package" above already built ui-kit's dist once for all of
# lint/typecheck/tests/build in this same job (GitHub Actions steps run strictly sequentially), so
# these turbo invocations reuse it without needing @loopover/ui#lint/@loopover/ui-miner#lint to
# declare their own ^build dependsOn (they intentionally don't -- eslint needs no compiled dist).
# Two --filter flags means "either package" (union), not intersection; both packages' tasks run
# even if one fails, so a failing @loopover/ui lint no longer hides an @loopover/ui-miner lint error
# behind it the way the old `&&` chain did -- both surface in one pass. Deliberately NOT changing the
# package.json ui:lint/ui:typecheck scripts themselves, since ui-deploy.yml and CONTRIBUTING.md's
# documented local gate sequence both call those directly and rely on their own ui:kit:build prefix.
- name: UI lint
if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.ui == 'true') }}
run: npx turbo run lint --filter=@loopover/ui --filter=@loopover/ui-miner
- name: UI typecheck
if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.ui == 'true') }}
run: npx turbo run typecheck --filter=@loopover/ui --filter=@loopover/ui-miner
# Split into 3 independent steps (was one `&&`-chained step) -- turbo.json deliberately has no `test`
# task for any package (per the plan doc: test orchestration stays out of Turborepo entirely, so
# Codecov's codecov/patch keeps one centralized source of truth), so this can't use the same
# union-`--filter` fix already applied to "UI lint"/"UI typecheck" two steps above. Plain `&&` meant a
# failing @loopover/ui test silently prevented @loopover/ui-miner's tests from ever running in that
# invocation -- same class of failure-masking bug those two steps'
# own comment already describes, just still present here. `!cancelled()` on all 3 (not `always()`)
# matches the pattern already used by "Save Turborepo cache"/"Save TypeScript incremental build
# cache" elsewhere in this file: still run after an earlier step's failure, just not after the job
# was cancelled outright. The job as a whole still fails if any of the three fails.
- name: UI tests (ui)
if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.ui == 'true') }}
run: npm --workspace @loopover/ui run test
- name: UI tests (ui-miner)
if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.ui == 'true') }}
run: npm --workspace @loopover/ui-miner run test
# `npm run ui:build` also regenerates apps/loopover-ui/public/openapi.json (needed for a
# standalone build), but this step's trigger condition is a strict subset of "OpenAPI drift
# check" above (push || ui==true, vs. push || ui==true || uiContract==true), so whenever this
# step runs, that check already ran and passed in this same job -- the committed spec is
# already byte-identical to what regenerating it here would produce. apps/loopover-ui's own
# `build` script (vite build) doesn't touch openapi.json either, so routing through Turborepo
# preserves that skip: @loopover/ui#build's dependsOn (turbo.json) is just ["^build"] (ui-kit,
# already a same-job cache hit from "Build UI-kit package" above).
- name: UI build
if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.ui == 'true') }}
run: npx turbo run build --filter=@loopover/ui
# Paired with "Restore Turborepo cache" above (same key, same gate condition) -- deliberately
# positioned here, after every turbo-routed step in this job (Build engine package, Build MCP, Build
# miner CLI, Build UI-kit package, UI lint/typecheck, UI build), rather than
# immediately after "Build engine package" the way it was originally: actions/cache/save takes one
# synchronous snapshot at the moment it runs, so anything written to .turbo/cache by a later step
# would never be captured if this ran earlier. `!cancelled()` (not `always()`) still saves whatever
# was successfully built even if an earlier step in this job failed, as long as the job wasn't
# cancelled outright.
- name: Save Turborepo cache
if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.miner == 'true') }}
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .turbo/cache
key: turbo-code-${{ hashFiles('package-lock.json') }}-${{ github.run_id }}
# Bundle-size regression tracking for the deployed marketing/product site (loopover-ui only --
# loopover-miner-ui is a self-hosted operator dashboard with no build step in this job at all, see
# "UI build" above). Fork-excluded (no secrets.CODECOV_TOKEN on a fork PR, same boundary as the
# coverage-upload steps below) and non-blocking (continue-on-error): unlike coverage, nothing gates
# merges on bundle size, so an upload hiccup must never fail CI. Uses @codecov/bundle-analyzer's
# standalone CLI against the built dist/client directly rather than a bundler-plugin hook --
# @codecov/vite-plugin's latest release doesn't support this repo's Vite 8 yet (peer range caps at
# 6.x; this repo's Vite 8 runs on rolldown, not classic rollup, so @codecov/rollup-plugin doesn't fit
# either) -- revisit wiring the plugin in once upstream catches up.
- name: Upload UI bundle stats to Codecov
if: ${{ (github.event_name == 'push' || needs.changes.outputs.ui == 'true') && github.event.pull_request.head.repo.fork != true }}
continue-on-error: true
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
run: npm run bundle-analysis --workspace @loopover/ui
# The full-suite coverage run (#ci-shard-coverage kept it OUT of validate-code so the ~25-minute suite
# never serializes with the fast drift/typecheck/build checks -- that split remains). It ran 2023-style
# as a 3-shard matrix for wall-clock; unsharded again 2026-07-24 (see the job's own header comment) to
# trade PR latency for 2 fewer runners per PR and the simpler, flake-free single-report pipeline.
validate-tests:
name: validate-tests
needs: changes
# Also runs on REES/mcp/engine/miner/discoveryIndex-only changes: a PR scoped to just one of those
# self-contained packages doesn't otherwise touch `backend`, so without this job it would upload no
# coverage report at all and codecov/patch would never post -- not even a vacuous pass, no check at
# all, and the required `validate` aggregate treats a skipped job as success (#7318-#7321: 3
# packages/loopover-miner/lib-only PRs merged this way with validate-tests never running). Running it
# guarantees the upload so Codecov posts a real patch verdict covering whichever package actually
# changed (review-enrichment/** via the `rees` flag from validate-code; packages/loopover-mcp,
# packages/loopover-engine, packages/loopover-miner, and packages/discovery-index via their entries in
# vitest.config.ts's coverage.include, exercised by the root test/** suite this job already runs).
# Draft PRs still skip the whole fan-out (#6448), so these triggers only apply to ready PRs.
#
# UNSHARDED (2026-07-24, queue-pressure + reliability consolidation): this ran as a 3-shard matrix
# (#ci-shard-coverage) to cut wall-clock; at the one-shot gate's PR volume those 2 extra runners per
# PR were a major driver of org-wide concurrency saturation (observed: 60-run queue), and the shard
# seams were a recurring flake/complexity source (blob merges, per-shard threshold suppression,
# duration-aware bin-packing, premature partial Codecov verdicts needing after_n_builds). One slower
# job trades ~10-15 min of PR latency for 2 fewer runners per PR, one honest whole-suite coverage
# report (the same signal `npm run test:coverage` gives locally), an inline global threshold check,
# and no merge job at all.
if: ${{ github.event_name == 'push' || (github.event.pull_request.draft != true && (needs.changes.outputs.backend == 'true' || needs.changes.outputs.rees == 'true' || needs.changes.outputs.controlPlane == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.discoveryIndex == 'true')) }}
runs-on: ubuntu-latest
timeout-minutes: 45
env:
VITE_LOOPOVER_API_ORIGIN: https://api.loopover.ai
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# Full history so Codecov can resolve the merge base for patch coverage.
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
# See .github/actions/setup-workspace (npmrc, Node, node_modules cache -- same cache key formula
# as validate-code, so a lockfile-unchanged PR gets a cache hit here too and skips npm ci
# entirely; concurrent jobs racing to save the same key is safe, actions/cache/save no-ops if
# another job already wrote it).
- name: Setup workspace
uses: ./.github/actions/setup-workspace
# Any backend test run needs the engine package's dist/ built first -- see the identical step's
# comment in validate-code (#ci-engine-build-order) for why. Same accumulating .turbo/cache
# restore/save pattern as validate-code's pair, but a DIFFERENT key prefix ("turbo-tests-" vs.
# validate-code's "turbo-code-"): these two jobs used to share one plain "turbo-" prefix, which meant
# all 7 jobs in a single run (validate-code plus these 6 shards) raced for the same cache slot, and
# actions/cache/save no-ops on a duplicate key within a run -- see the shard-vs-shard version of that
# same race below. Because a shard reaches its own Save after only ~6 steps versus validate-code's
# ~20+, a shard almost always won that race, meaning validate-code's much richer cache (engine + mcp +
# miner + ui-kit + ui) was very likely discarded every run in favor of a shard's
# engine-only one. Splitting the prefixes gives each job's own lineage instead of racing; within THIS
# prefix, the 6 shards below still race each other for one slot, which is fine since they all save the
# exact same content (engine + mcp + miner builds all run in this job) -- whichever shard wins, the
# content is equivalent, unlike the cross-job case this split just fixed.
- name: Restore Turborepo cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .turbo/cache
key: turbo-tests-${{ hashFiles('package-lock.json') }}-${{ github.run_id }}
restore-keys: |
turbo-tests-${{ hashFiles('package-lock.json') }}-
- name: Build engine package
run: npx turbo run build --filter=@loopover/engine
# packages/loopover-mcp and packages/loopover-miner both ship .ts source only (no compiled .js
# committed -- gitignored, and since the 2026-07-24 dist/ migration built out-of-place into dist/,
# same as @loopover/engine's own dist/; see each package's tsconfig.json outDir comment). Every
# mcp-cli-*/miner-*/mcp-feasibility-gate-style test spawning a real CLI subprocess -- whether via
# the shared test/unit/support/{mcp,miner}-cli-harness.ts or its own hand-rolled StdioClientTransport
# -- runs the real compiled dist/bin/*.js directly via plain `node` now (no `--experimental-strip-
# types`; that was only ever viable while the compiled .js sat in-place next to the .ts source for
# Node's own resolver to find its internal lib/*.js imports, which stopped being true once dist/
# physically separated them -- see the harness files' own comments). So bin/lib's compiled output has
# to physically exist on disk before any of those tests run. Before the compiled output stopped being
# committed, this job's checkout already had it for free; now it needs building here too, same as
# validate-code's own "Build MCP"/"Build miner CLI" steps.
#
# --force on both: turbo.json's @loopover/mcp#build task now declares real `outputs` (see its own
# comment) so a cache HIT restores the actual files instead of only the log, but this job's 3 parallel
# shards still race on ONE shared turbo-tests- cache key/slot (below), and a hit-from-a-sibling-shard
# is exactly the kind of cross-job interaction that's expensive to keep re-verifying under. --force
# makes this job's own correctness independent of that entirely: it always executes for real here,
# never trusts a hit (from any source, this run or a prior one) for the two packages hundreds of
# downstream tests treat as load-bearing. The packages are small (a few seconds each, confirmed
# locally) -- this is a deliberate, small, fixed cost traded for eliminating an entire class of
# cache-hit-but-file-missing failures, not a workaround. Unconditional (no `if:`), matching "Build
# engine package" immediately above; --force doesn't skip the write side, so this run's own build
# still contributes a correct, restorable cache entry for anything else that reads the turbo-tests-
# prefix (this job's own next run, or a future validate-tests-only invocation).
- name: Build MCP
run: npx turbo run build --filter=@loopover/mcp --force
- name: Build miner CLI
run: npx turbo run build:tsc build:verify --filter=@loopover/miner --force
# Fail fast and unambiguously, right here, if either binary is somehow still missing after the two
# --force builds above -- instead of dozens of confusing per-test MODULE_NOT_FOUND/ENOENT failures far
# downstream in "Test with coverage" that all look unrelated to each other and to this step. This
# should be structurally impossible now (both builds above are forced, never cache-skipped), but it's
# a one-line, load-bearing tripwire for a class of bug that has already cost real debugging time twice.
- name: Verify MCP/miner CLI binaries were actually built
run: |
set -euo pipefail
for f in packages/loopover-mcp/dist/bin/loopover-mcp.js packages/loopover-miner/dist/bin/loopover-miner.js; do
if [ ! -f "$f" ]; then
echo "::error::$f is missing after Build MCP/Build miner CLI both ran with --force. Every mcp-cli-*/miner-* test below will fail with a confusing MODULE_NOT_FOUND/ENOENT instead of this message -- something deeper changed (e.g. the build script itself stopped writing to this path); this is not a cache/timing issue, --force rules that out."
exit 1
fi
done
# This job runs the general test/unit/*.test.ts suite unconditionally whenever it runs at all
# (unscoped on push, and SCOPED_TEST_SELECTION below only excludes mcp-cli-*/miner-* prefixed
# files, never this one) -- so test/unit/check-ui-kit-package.test.ts's own real-package
# regression guard needs packages/loopover-ui-kit/dist/ to exist here too, the same gap "Build
# engine package"/"Build MCP"/"Build miner CLI" above already close for their own pack-checks
# (#8592 confirmed live: this job has no ui-kit build at all, unlike validate-code).
- name: Build UI-kit package
run: npx turbo run build --filter=@loopover/ui-kit
- name: Save Turborepo cache
if: ${{ !cancelled() }}
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .turbo/cache
key: turbo-tests-${{ hashFiles('package-lock.json') }}-${{ github.run_id }}
- name: Prepare test reports dir
run: mkdir -p reports/junit
- name: Test with coverage
id: coverage
env:
# Disables vitest.config.ts's own global 90% threshold check ONLY for scoped-selection runs (the
# SCOPED_TEST_SELECTION expression below, verbatim): a scoped run exercises a narrow slice of the
# tree, so the whole-include-set threshold would always false-fail it (Codecov's patch gate still
# enforces real per-line coverage on the scoped diff). Full-suite runs keep the threshold INLINE --
# unsharded, this job sees the whole suite, so the "loose catastrophe net" (e.g. a deleted test
# file) that previously required the separate validate-tests-merge job now just... runs here.
COVERAGE_NO_THRESHOLDS: ${{ (github.event_name == 'pull_request' && vars.SCOPED_TEST_SELECTION_ENABLED != 'false' && needs.changes.outputs.rees != 'true' && needs.changes.outputs.controlPlane != 'true' && needs.changes.outputs.engine != 'true' && needs.changes.outputs.backendConfig != 'true' && (needs.changes.outputs.backend == 'true' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.discoveryIndex == 'true')) && 'true' || '' }}
# Same self-contained-test-file narrowing as the pre-sharding job had -- see the `mcpCliHarness`/
# `minerTestHarness` filter comments in the `changes` job above for the full rationale.
SKIP_MCP_CLI_HARNESS: ${{ github.event_name == 'pull_request' && needs.changes.outputs.mcpCliHarness != 'true' }}
SKIP_MINER_TEST_HARNESS: ${{ github.event_name == 'pull_request' && needs.changes.outputs.minerTestHarness != 'true' }}
# Scoped test selection (#ci-scoped-test-selection): a PR touching miner/mcp/discoveryIndex/backend --
# never rees or engine -- runs vitest's own --changed against origin/main instead of the full
# ~2,900+-test suite, which is otherwise massive overkill for a diff touching a handful of files. engine is deliberately EXCLUDED
# from this fast path: it's the one package other code imports through a built, gitignored dist/ that
# --changed's import-graph analysis can't safely trace across a node_modules/package.json-exports
# boundary -- verified by reproducing a real missed regression (editing packages/loopover-engine/
# src/ directly, --changed failed to select the miner-side wrapper-integration tests that actually
# exercise it). rees stays excluded too (unreviewed, no evidence gathered either way -- narrower
# scope of caution costs little since it's a small slice of PRs).
#
# backend (src/**) was added to this fast path after direct verification, not by assumption: 5 real
# historical single/few-file src/**-only commits (spanning orb, review, api, db, selfhost -- see
# loopover#7590-era CI investigation) were replayed with the target commit actually checked out and
# --changed run against its parent. Every one correctly included its own direct dependent test
# file(s); zero false negatives found. Selection size scaled sensibly with how foundational the file
# was (a leaf module: ~0.1% of the suite; the shared DB repositories layer: ~31%) -- always less than
# the unscoped 100%, never more. That verification covered plain .ts source-file diffs only -- never
# a migration, package.json/tsconfig/vitest.config, or workflow/tooling change, none of which have
# any import-graph edge a test file could trace to them. `backendConfig` (the `changes` job above)
# captures exactly that non-traceable subset of `backend` and is required false here so a PR
# touching one still gets the full unscoped suite even if it also touches src/**. The one
# deliberately-unfiltered wide-fan-out file,
# test/unit/mcp-output-schemas.test.ts (imports src/mcp/server.ts, which alone directly imports ~40
# other src/ modules -- see the `mcpCliHarness` filter comment in the `changes` job above), is NOT
# given special-casing here: unlike the static path-filter that comment describes, --changed is a
# live, same-source-tree import-graph trace, which this batch of verification confirmed handles
# direct in-repo imports correctly (only the engine dist/ package-boundary case above is unsafe).
# Confirmed live: combining --changed with an explicit positional filename NARROWS the selection
# (intersection) rather than unioning it, so that composition was deliberately NOT used to force this
# file in -- doing so would have silently dropped every other selected file instead.
#
# miner/mcp/discoveryIndex don't have engine's exposure either: nothing else in the repo imports them
# as libraries, so their tests are reached via direct same-package imports, verified to trace
# correctly. Every push to main still runs the full unscoped suite unconditionally (this job's own
# top-level `if:` above is unchanged), so a scoped miss is caught within minutes of merge, not never.
# Kill switch: set the repo variable SCOPED_TEST_SELECTION_ENABLED to "false" to force the full suite
# for every PR (e.g. if a gap is ever found) without a revert PR.