-
Notifications
You must be signed in to change notification settings - Fork 10
1896 lines (1787 loc) · 101 KB
/
Copy pathci.yml
File metadata and controls
1896 lines (1787 loc) · 101 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:
push:
branches:
- main
pull_request:
branches:
- main
# Merge queue: the queue builds each PR as speculatively merged onto the
# current main and only lands it if this workflow is green on that result —
# the race-free version of the "pull main and re-verify before merging"
# discipline (AGENTS.md multi-agent §7/§10). Every workflow that produces a
# branch-protection-required check MUST carry this trigger, or queue builds
# wait forever on a check that never reports.
merge_group:
# Superseded runs on the same PR/branch waste runners and delay feedback;
# cancel them. Push runs to main group by commit ref as well, so an in-flight
# main run is cancelled only by a newer main push.
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
filter:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
outputs:
# On merge_group, everything counts as changed: dorny/paths-filter has no
# merge_group support, and the queue build is the last validation before
# main — the one place a skipped job can never be the right answer. A
# skipped step's output is the empty string (falsy), so `|| 'true'`
# supplies the merge-group value without touching PR/push behavior.
docs: ${{ steps.changes.outputs.docs || 'true' }}
core: ${{ steps.changes.outputs.core || 'true' }}
console: ${{ steps.changes.outputs.console || 'true' }}
# Gates the `test` job ONLY, ORed with `core` — see the `crosspkg`
# filter below and the `test` job's `if:` (#9829, #10015).
crosspkg: ${{ steps.changes.outputs.crosspkg || 'true' }}
# ── THE FILTER CONTRACT, both halves (#4928) ──────────────────────────
#
# Half 1 is the `|| 'true'` above: when in doubt, RUN EVERYTHING. It
# covers a missing output VALUE.
#
# Half 2 is every downstream `if:` in this file, and it used to be
# missing. GitHub wraps an `if:` that names no status function in an
# IMPLICIT success(), so the original `if: needs.filter.outputs.core ==
# 'true'` had TWO independent paths to "skip" when this job DIED
# (checkout flake, a dorny/paths-filter fault, the 10-minute timeout):
# the implicit success() is false, and the output is no longer a
# trustworthy 'true'. Both roads led to skipping test / dogfood /
# build-core — and skipped counts as SUCCESS in branch protection, so a
# single flake here produced a fully green, zero-test-run, mergeable PR
# with no red signal anywhere.
#
# So every downstream job now spells the contract as "skip only when the
# filter EXPLICITLY said false":
#
# if: ${{ !cancelled() && needs.filter.outputs.<name> != 'false' }}
#
# `!cancelled()` displaces the implicit success() (a genuine run-level
# cancellation still skips — #3668's lifecycle reasoning); `!= 'false'`
# makes the empty string mean "run", exactly like `|| 'true'` does. This
# is deliberately robust to BOTH readings of what a failed job's outputs
# are — null/empty or the `|| 'true'` fallback — because `'' != 'false'`
# and `'true' != 'false'` are both true. The old form was broken under
# both, because the implicit success() dominates either way.
#
# Third occurrence of this GitHub semantic in one audit; the other two
# were release.yml's publish-integrity guard and its docker job (#4900).
steps:
- name: Checkout repository
uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
id: changes
if: github.event_name != 'merge_group'
with:
filters: |
docs:
- 'apps/docs/**'
- 'content/**'
- 'pnpm-lock.yaml'
- '.github/workflows/ci.yml'
core:
- 'packages/**'
- 'examples/**'
- 'apps/!(docs)/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'tsconfig.json'
- '.github/workflows/ci.yml'
# Build inputs of the vendored Console SPA — see the Console Pin Gate
# job at the bottom of this file. `.objectui-sha` is a ROOT DOTFILE, so
# it matches neither filter above: a pin-only diff skipped `core` and
# `docs` alike, which is how #4288 moved the pin 76 commits with six of
# fourteen checks skipped and nothing anywhere building the new SHA.
# The last two entries are the "a change to the guard runs the guard"
# rule the repo applies to every other filtered gate; they are close to
# free here because an unmoved pin hits the dist cache.
console:
- '.objectui-sha'
- 'scripts/build-console.sh'
- 'scripts/check-console-sha.mjs'
- 'scripts/check-console-injection.mjs'
- 'scripts/console-spec-probes.mjs'
- 'scripts/assert-console-spec-injection.mjs'
- '.github/workflows/ci.yml'
# Test inputs that live OUTSIDE every package (#9829, #10015). Packages
# whose suites read across their own boundary declare that radius in
# scripts/check-cross-package-test-inputs.mjs, and turbo.json mirrors each
# as a `$TURBO_ROOT$/…` input. The step that pulls those packages back into
# the affected set, `--union-into` in the `test` job below, is a step
# INSIDE that job — so when NO filter matches the changed path the job
# never starts, the union never runs, and the declaration buys nothing at
# PR time: the merge queue is the first signal, which is the #7802 failure
# this whole mechanism exists to prevent.
#
# Measured by instantiating every one of turbo.json's `$TURBO_ROOT$`
# declarations to a real tracked file and running it through these filters
# with picomatch — the matcher dorny/paths-filter uses — under BOTH 2.3.1
# (what the action's own lockfile resolves and ncc-bundles) and 4.0.5 (what
# this tree resolves); the two agree on every row. On `0bfbeb70c`, 19 of 64
# declarations were core=false. #9829 closed the nine rooted at `scripts/`;
# the four roots added below are the remaining ten (#10015).
#
# Deliberately a SEPARATE output rather than a widening of `core`, because
# it gates ONE job — the `test` job ORs it with `core`, while Build Core,
# Dogfood and Temporal Conformance stay filtered exactly as they are today.
# That separation is what makes `content/**` affordable at all. Widening
# `core` would put the whole core pipeline on every docs PR; here a
# docs-only diff starts Test Core, whose package set is `turbo ls
# --affected` (empty for such a diff) UNIONED with the packages whose
# declarations matched — so it runs the declaring package, not the
# workspace. Measured through the real `--union-into` on all three
# content-only commits in the window below: `create-objectstack` alone,
# all three times, against a positive control where a
# `packages/lint/src/**` diff unions in @objectstack/spec and
# @objectstack/core.
#
# WIDTH, per entry — each is exactly what some package declared, never a
# root rounded up:
# `scripts/**` and `content/**` are declared verbatim, by
# @objectstack/spec and create-objectstack respectively, so the
# narrower filter does not exist and a hand-kept list of the paths
# inside them is the failure mode #7802 recorded ("a list you must
# remember to update"). Eight further single-file `scripts/`
# declarations and six narrower `content/docs/…` ones (spec, cli,
# dogfood) sit inside the two.
# The last four are a single file, a single subtree, a single file and a
# single file, because that is what the declarations say — NOT `docs/**`,
# `skills/**` or `.github/workflows/**`. Over the last 100 first-parent
# commits of `main` at `0bfbeb70c`, three of those roots would newly have
# started Test Core on 5, 2 and 3 commits; the declared paths on ZERO.
# `skills/objectstack-automation/SKILL.md` joined later (#10178) and was
# NOT in that window's measurement — it is declared by @objectstack/rest,
# one file, and the `skills/**` root it sits in is still declined here.
#
# PRICE, re-measured over that same 100-commit window through this matcher:
# Test Core scheduling goes 80/100 → 83/100. All three additions are
# content-only docs commits, and each runs one package's suite.
#
# This list is a SECOND recognizer of those declarations and is kept in
# step with them BY HAND: nothing fails today when a declaration lands in a
# root no entry here covers. Closing that is #10379.
crosspkg:
- 'scripts/**'
- 'content/**'
- 'docs/audits/2026-07-unknown-key-strictness-ledger.md'
- 'skills/objectstack-formula/**'
- 'skills/objectstack-automation/SKILL.md'
# @objectstack/spec's scripts/export-list.test.ts corpus gate reads
# the whole published catalog (#12201). Verbatim per the declaration;
# it subsumes the two narrower skills entries above, which are left
# as the packages that declared them spelled them.
- 'skills/**'
- '.github/workflows/scaffold-e2e.yml'
- '.claude/skills/spec-property-retirement/SKILL.md'
test:
# Sharded 6-way BY PACKAGE: a core-touching PR ran the affected suite
# ~11½ min on one 4-vCPU runner — the longest pole in the whole workflow —
# and at 2 shards the slower shard still ran ~10 min, keeping merge-queue
# builds at ~11 min end-to-end (#4859).
# scripts/partition-test-shards.mjs splits the package list into six
# deterministic bins balanced on MEASURED per-package test duration
# (scripts/test-shard-timings.json, generated — see #10472 below) and each
# shard runs its slice through turbo. NOT the dogfood
# job's vitest --shard passthrough, deliberately: that works for dogfood
# because it is ONE package with ~60 files, but applied workspace-wide,
# vitest 4 hard-fails every package with fewer test files than the shard
# count — and `--passWithNoTests` converts the failure into running NOTHING
# on either shard (three packages have exactly one test file today). See
# the script header for the verification.
#
# ── WHY SIX AND NOT THREE (#10227) ────────────────────────────────────
#
# The per-package durations this job's logs print are CONTENDED WALL
# CLOCK, not package cost. `--concurrency=4` on a 4-vCPU runner means up
# to four suites share four cores, so each averages about one core, and
# the contention factor differs per package (measured spread across six
# packages: 0.69x-2.05x). Read as cost, those numbers said the two long
# poles were inherently over #4859's <=7min (420s) line and that no shard
# count could help. Measured like-for-like against the same suites run
# ALONE on an idle 4-core box (#10152 / PR #10258), both clear it:
#
# @objectstack/cli 548.6s contended vs 337.13s alone
# @objectstack/spec 496.4s contended vs 325.31s alone
#
# So the thing over the line is the co-tenancy, not the suites. More
# shards means fewer suites per runner AND four more vCPUs per added
# shard, because every shard gets its own runner.
#
# SIX specifically, and not four or five: sharding is BY PACKAGE, so no
# shard can finish faster than its single heaviest package.
# `@objectstack/spec` is that package — at five shards or fewer the
# partitioner must co-schedule it with others, and at six it very nearly
# fills a bin on its own. Six is therefore the smallest count that
# isolates the heaviest indivisible suite; past six, spec's shard cannot
# improve, only the others can.
#
# ⚠ THE ARGUMENT ABOVE USED TO BE MADE IN TEST-FILE COUNTS (bins
# 415/389/389/389/389/388, spec carrying 415 of ~2360 files). It is now
# made in measured seconds, because #10472 replaced the weight input: the
# file count was a proxy that ran ~2.8x off on @objectstack/cli, and the
# six perfectly count-balanced bins it produced ran 5.0/6.2/6.3/13.6/6.3/
# 9.0 min in run 32428961038. The conclusion (six) survived the re-
# measurement; only the units it is argued in changed. Weights now come
# from scripts/test-shard-timings.json — see partition-test-shards.mjs.
#
# AND WHY NOT MORE THAN SIX, measured on that dataset (#10472 asked for
# 6 -> 8 to be considered). The floor is the heaviest single package, and
# it does not move when shards are added, while the mean falls with every
# shard — so max/mean, which is what the acceptance bound is written in,
# gets WORSE past the point where the floor becomes the max:
#
# 6 shards -> max 650s mean 649s ratio 1.00x
# 7 shards -> max 571s mean 556s ratio 1.03x
# 8 shards -> max 571s mean 487s ratio 1.17x
# 10 shards -> max 571s mean 389s ratio 1.46x
#
# Eight shards would buy ~79s off the critical path for two more runners'
# fixed overhead and a worse balance ratio. The partitioner's self-test
# pins this arithmetic so the next person gets the answer from a failing
# assertion rather than from a CI run.
#
# ⚠ THE COST, stated because it is real: per-shard fixed overhead
# (checkout + pnpm/Turbo cache restore + install, ~60s measured on run
# 32392712980) is paid once PER SHARD, and the merge queue multiplies it
# by the speculative build count. Six shards trade roughly 1.7x the
# billed runner-minutes for a shorter critical path. `--concurrency=4` is
# deliberately UNCHANGED here: narrower shards may well want a lower
# value, but moving both at once makes the next measurement unreadable.
#
# Branch protection requires the bare "Test Core" context, which a matrix
# can never publish again — the test-gate job below carries that name
# (the #3622 lesson; see dogfood-gate).
name: Test Core (${{ matrix.shard }}/6)
needs: filter
# "Skip only when the filter EXPLICITLY said no core paths changed" — see
# THE FILTER CONTRACT on the filter job's outputs (#4928). `scripts` is ORed
# in, never ANDed: two independent reasons to run this job, and it skips only
# when BOTH filters explicitly said false, so the contract's "when in doubt,
# RUN EVERYTHING" posture is unchanged (#9829).
if: ${{ !cancelled() && (needs.filter.outputs.core != 'false' || needs.filter.outputs.crosspkg != 'false') }}
runs-on: ubuntu-latest
# Backstop only — the stall guard on the test steps is the primary
# detector for a #4250-style hang and fires well before this. 30 min is
# ~5× a normal sharded run (~4-6 min), with margin for a cold Turbo cache;
# the old 45 left a hung job "running" for half an hour past any plausible
# healthy finish.
timeout-minutes: 30
permissions:
contents: read
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6]
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
# Full history so `turbo --affected` can diff against the PR base.
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Setup pnpm
uses: ./.github/actions/setup-pnpm
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v6
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-v3-
# Restore-only on PRs: PR-side saves (~5 turbo entries per push) churned
# the repo's 10 GB Actions cache pool and evicted the main-branch seeds —
# observed as sudden cold-cache spikes (Build Core 51s → 4m30s). PRs fall
# back to main's entries via the prefix restore-keys; only main pushes
# save (the "Save Turbo cache" step at the end of the job). Shard-scoped
# key: each shard builds/tests a different half of the workspace (same
# pattern as dogfood). Jobs that used to fall back to this job's
# namespace fall back to Build Core now — neither single shard builds a
# superset anymore.
- name: Restore Turbo cache
uses: actions/cache/restore@v6
with:
path: .turbo/cache
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ matrix.shard }}-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-${{ github.job }}-${{ matrix.shard }}-${{ github.ref_name }}-
${{ runner.os }}-turbo-${{ github.job }}-${{ matrix.shard }}-
${{ runner.os }}-turbo-${{ github.job }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
# PRs: only test packages affected by the diff against the PR base.
# spec sits at the root of the dependency graph, so spec-touching PRs
# still run (close to) everything — but the many PRs that don't touch
# spec skip the bulk of the 75-package matrix. Push to main and
# merge-queue builds partition the FULL package list instead: the queue
# result IS the next main, so it gets main's validation, not the PR's
# affected-only subset. (Spec's suite runs here plain / uninstrumented;
# the coverage-instrumented pass lives in the nightly coverage-nightly
# workflow.)
#
# !@objectstack/dogfood: the ~7½-minute dogfood suite is the dedicated
# Dogfood job's whole purpose, and both jobs run under the same `core`
# filter — without the exclusion every core PR executed the suite twice
# in parallel, and it dominated this job's critical path.
#
# `turbo ls` is experimental; the partition script asserts its output
# shape loudly so an upgrade that changes it turns into a red step
# naming the cause, not a silently empty shard. An EMPTY shard file must
# short-circuit the test step below: `turbo run test` with zero --filter
# args runs the entire workspace.
#
# WHERE THE AFFECTED DIFF STARTS (#6195, the #6129 family's third
# consumer). The one thing this base must never be is
# `github.event.pull_request.base.sha`.
#
# The payload's `base.sha` is frozen when the PR is OPENED and does not
# move on `synchronize`. HEAD, meanwhile, is the merge ref
# (`refs/pull/N/merge`) that the checkout above resolves by default on a
# `pull_request` event — no `ref:` is given, so this job stands on a merge
# commit containing everything main has today. Everything main gained
# while the PR sat open therefore lands between the two, and
# `turbo ls --affected` reads it as this PR's own changes: packages only
# SOMEBODY ELSE's merged PR touched get tested on this shard.
#
# The direction is conservative — the frozen base is an ancestor of HEAD,
# so its file set is a strict SUPERSET of this PR's own. Nothing that
# should run is skipped; what degrades is the optimisation itself, and it
# degrades with how long the PR has been open. At ~18 merges a day an
# affected-only shard drifts back toward a full run.
#
# Measured on turbo 2.10.7 against a real merge-ref fixture (base branch
# moved 1 commit touching pkg-b; this PR touched pkg-a only):
# TURBO_SCM_BASE=<frozen base.sha> -> pkg-a, pkg-b
# TURBO_SCM_BASE=merge-base(origin/main,HEAD) -> pkg-a
#
# Four spellings that look like the fix and are not:
# - `base.sha...HEAD` (three dots). Three-dot means
# `merge-base(base.sha, HEAD)..HEAD`, and the frozen sha is ALREADY an
# ancestor of HEAD, so it IS its own merge base and the set does not
# move. Measured: still both packages. (pr-automation.yml records the
# same result for its own diff — same fact, one family.)
# - `HEAD^1`. Correct on a merge ref and silently wrong the day someone
# gives this checkout a `ref:`, where parent^1 becomes the PR's
# previous commit. `merge-base` is right under BOTH checkouts.
# - Dropping the variable and letting turbo default to `main`. Measured
# in a CI-shaped clone: `fetch-depth: 0` populates
# `refs/remotes/origin/*`, NOT local heads, so there is no local `main`
# — and turbo does not error, it silently returns the ENTIRE
# workspace, untouched packages included. Safe, and the whole
# optimisation gone.
# - `TURBO_SCM_BASE=origin/$BASE_REF`, letting turbo resolve the ref.
# Measured correct today, but it rests the shard's package set on
# `turbo ls`'s internal choice of dot-ness — undocumented, and
# `turbo ls` is experimental (see above). Resolving to a commit here
# leaves turbo no choice to make.
- name: Compute this shard's package set
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
PINNED_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
SCM_BASE=''
if [ "${{ github.event_name }}" = "pull_request" ]; then
if [ -z "$BASE_REF" ]; then
echo "::warning::This pull_request event carries no base branch, so the affected-set diff base cannot be computed."
else
# `fetch-depth: 0` above already makes this resolve — the fetch is
# the guard for the day that changes, not the normal path.
#
# `git cat-file -e` rather than the more idiomatic strict
# `git rev-parse --verify` spelling. That is history rather than
# style, and it is written down because it used to be a live
# hazard: the pre-#6589 check-shard-attestation.mjs classified a
# job as an aggregate GATE when the script's basename and its
# `--verify` flag merely CO-OCCURRED as substrings anywhere in
# the job's joined `run:` text. This job always carries the
# basename (its `--emit` step at the bottom), so spelling
# `--verify` anywhere in this step — comments included, since
# they were part of `run:` — silently reclassified the shard job
# as a gate.
#
# #6589 closed that by construction. Classification is now by
# INVOCATION: within one command the flag must follow the
# script's own name as an argument, the test is applied per STEP
# and never over the job's joined text, and the lexer drops shell
# comments — all three pinned by that script's `--self-test`,
# which is why this comment can now name the flag at all. Either
# spelling is safe here; `git cat-file -e` stays because
# churning it would buy nothing.
if ! git cat-file -e "refs/remotes/origin/$BASE_REF^{commit}" 2>/dev/null; then
git fetch --no-tags --quiet origin "+refs/heads/$BASE_REF:refs/remotes/origin/$BASE_REF" \
|| echo "::warning::Could not fetch origin/$BASE_REF; the merge-base resolution below will decide."
fi
# `if !` rather than a bare assignment on purpose: these steps run
# under `bash -e`, where a failing command substitution kills the
# step with no message at all.
if ! SCM_BASE=$(git merge-base "refs/remotes/origin/$BASE_REF" HEAD); then
SCM_BASE=''
fi
fi
fi
if [ -n "$SCM_BASE" ]; then
# The drift is printed, not just corrected: nothing in this log ever
# said which commit the affected diff started from, which is why the
# decay was invisible.
DRIFT=$(git rev-list --count "$PINNED_BASE_SHA..$SCM_BASE" 2>/dev/null || echo '?')
echo "Affected-set diff base: $SCM_BASE (merge-base of origin/$BASE_REF and HEAD)"
echo "Frozen payload base.sha: $PINNED_BASE_SHA -- $BASE_REF has moved $DRIFT commit(s) since it was frozen, and that drift is exactly what this step used to charge to this PR."
TURBO_SCM_BASE="$SCM_BASE" pnpm exec turbo ls --affected --output=json > "$RUNNER_TEMP/turbo-ls.json"
# `turbo ls --affected` answers "which packages does the dependency
# GRAPH reach from this diff" — and some suites read files the graph
# does not connect them to. spec's api-methods-batch-conformance scan
# walks every `*.object.ts` in the monorepo while spec declares no
# dependency on the packages it judges (nor should it: the scan reads
# source text precisely to avoid inverting the spec -> * direction).
# A platform-objects-only diff therefore left it unrun, and #7769
# landed a violation on `main` that only PRs touching `spec` ever saw.
# Measured on turbo 2.10.7: 51 packages affected by that diff, spec
# not among them.
#
# So packages that declare a cross-package input radius are unioned
# back in when the diff touches it. The declarations, and the static
# detector that refuses to let a new cross-package scan go
# undeclared, live in the script (`pnpm check:cross-package-test-inputs`).
#
# Failure here falls back to the FULL package list, never to the
# affected-only set: same posture as the merge-base fallback below
# (#6195), and the same reason — the full list is a strict superset,
# so doubt costs minutes rather than coverage. This is the FILTER
# CONTRACT's half 1 applied one layer down.
if ! git diff --name-only "$SCM_BASE" HEAD > "$RUNNER_TEMP/changed-files.txt" \
|| ! node scripts/check-cross-package-test-inputs.mjs \
--union-into "$RUNNER_TEMP/turbo-ls.json" \
--changed "$RUNNER_TEMP/changed-files.txt"; then
echo "::warning::Could not union cross-package scans into the affected set; falling back to the full package list for this shard."
pnpm exec turbo ls --output=json > "$RUNNER_TEMP/turbo-ls.json"
# A fourth signal the failure branches do not cover: a producer that
# exits 0 with a wrong, plausible, EMPTY answer. Only that `git
# diff`'s exit STATUS was checked, never its emptiness, so a
# merge-base resolving to something wrong-but-valid gave an empty
# changed-file list -> zero affected packages -> a green shard that
# tested nothing, with every log line reading like a normal quiet PR
# (#10057). The shard attestation (#6082) does not cover it: it
# attests "shard N ran and every step passed", which is exactly what
# a shard that tested nothing does.
#
# Empty is decidable as BROKEN here, and only here: a pull_request
# always differs from its merge-base. At the partitioner zero is
# frequently the CORRECT answer (a docs-only PR genuinely affects no
# package, and with 6 shards a small change legitimately leaves
# shards empty), so a blanket "red on empty" belongs there least of
# all -- this is the one place selection-failed and nothing-selected
# can be told apart.
#
# Scoping this to pull_request needs no `github.event_name` test:
# SCM_BASE is assigned only inside the `pull_request` guard above, so
# this whole `[ -n "$SCM_BASE" ]` branch is unreachable on push and
# merge_group. Those keep taking the full-list path in the `else`
# below, by design.
elif [ ! -s "$RUNNER_TEMP/changed-files.txt" ]; then
echo "::warning::The diff against merge-base $SCM_BASE listed no changed files, which a pull_request cannot legitimately produce; falling back to the full package list for this shard rather than selecting nothing (#10057)."
pnpm exec turbo ls --output=json > "$RUNNER_TEMP/turbo-ls.json"
fi
else
# Falling back to the FULL package list, never to the frozen
# base.sha. This is not the #4690 silent-skip anti-pattern: that is
# about a gate PASSING on input it could not read, and the full list
# is a strict superset of the affected one — this shard still runs
# everything it would have run and more. Cost is minutes; the
# alternative is a red Test Core on a PR with nothing wrong with it.
# Push and merge-queue builds take this branch by design (the queue
# result IS the next main, so it gets main's validation).
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "::warning::Could not resolve merge-base(origin/$BASE_REF, HEAD); falling back to the full package list for this shard rather than diffing from the frozen base.sha (#6195)."
fi
pnpm exec turbo ls --output=json > "$RUNNER_TEMP/turbo-ls.json"
fi
node scripts/partition-test-shards.mjs "$RUNNER_TEMP/turbo-ls.json" \
--shard ${{ matrix.shard }}/6 --exclude @objectstack/dogfood \
> "$RUNNER_TEMP/shard-packages.txt"
echo "Packages on this shard:"
cat "$RUNNER_TEMP/shard-packages.txt"
# --concurrency=4: turbo's default (10) oversubscribes the 4-vCPU
# hosted runner; matching the core count bounds peak memory and the
# job is CPU-bound anyway.
# run-with-stall-guard replaces the old `… 2>&1 | tee $log` +
# `set -o pipefail` idiom: the guard tees combined output to the log
# itself and propagates the suite's real exit status, so there is no
# pipe whose status tee could mask (do not reintroduce `| tee`). Its
# actual job is #4250: a run whose output freezes mid-suite while the
# job sits in_progress. Silence past --stall-minutes is declared a
# stall — a labeled red naming the last output line — instead of a
# 20-minute wait for a human (or the job timeout) to notice. 10 min is
# ~5× the longest healthy quiet gap.
#
# NODE_OPTIONS arms every node process (vitest workers included) to dump
# a diagnostic report on SIGUSR2; on a stall the guard signals the frozen
# process group and digests the reports into the log — exact JS stack for
# a process whose event loop is alive, and a named "no report = blocked
# loop" verdict for one that is sync-spinning. The next #4250 occurrence
# identifies its own culprit instead of costing a diagnosis.
#
# --log-order=stream is MANDATORY under the guard, not a preference. The
# guard measures output FLUSHES; turbo's default in CI is grouped log
# order, which flushes a task's output only when the task ENDS — so a
# shard whose tail is one task longer than --stall-minutes was killed BY
# CONSTRUCTION, healthy or not (measured: a healthy cli:test killed at
# exit 75 with 173/173 files passing in the flush the kill forced). The
# guard now refuses to wrap a turbo run without this flag. Cost is
# interleaved logs; the completeness guard below reads the per-line
# `<pkg>:test:` prefixes stream order emits (a pinned parseSummaries
# branch), so attribution survives.
- name: Run this shard's tests
env:
NODE_OPTIONS: --report-on-signal --report-signal=SIGUSR2 --report-directory=${{ runner.temp }}/stall-reports
run: |
if [ ! -s "$RUNNER_TEMP/shard-packages.txt" ]; then
echo "No packages on this shard — nothing to test."
exit 0
fi
# Bound vitest's INNER worker pool (#11958). A no-op on a runner with
# <= 5 cores (it only ever LOWERS vitest's own `cores - 1` default);
# it exists so a larger runner cannot multiply turbo's outer
# --concurrency by a host-sized inner pool. Empty on failure, which is
# vitest's own "use the default" signal. Needs turbo.json's
# globalPassThroughEnv entry or turbo strips it — see the script header.
export VITEST_MAX_WORKERS="$(node scripts/vitest-worker-cap.mjs)"
FILTERS=$(sed 's/^/--filter=/' "$RUNNER_TEMP/shard-packages.txt" | tr '\n' ' ')
mkdir -p "$RUNNER_TEMP/stall-reports"
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/test-core.log" --stall-minutes 10 \
--report-dir "$RUNNER_TEMP/stall-reports" -- \
pnpm turbo run test $FILTERS --concurrency=4 --summarize --log-order=stream
# --summarize above costs nothing at runtime and writes
# `.turbo/runs/<id>.json`: one per-task record with the execution window
# turbo actually observed. That file IS the balancing input for the next
# shard split (scripts/test-shard-timings.json is generated from it), and
# publishing it here is what keeps the dataset re-measurable instead of
# hand-frozen — refreshing it becomes "download these six artifacts from
# any green run and re-run the generator", with no special measurement
# run and no CI time spent measuring.
#
# `if: always()` so a shard that went red still publishes what it timed;
# the generator refuses failed and cache-replayed tasks on its own side,
# so a partial upload contributes nothing rather than something wrong.
#
# ⛔ This step must stay ABOVE the attestation pair at the bottom of the
# job. check-shard-attestation.mjs requires `--emit` + upload-artifact to
# be the job's LAST TWO steps, because anything after them can fail the
# job while the credential already counts as a pass (#6082). Adding an
# upload here is fine; moving it below them is not.
- name: Publish this shard's turbo run summary
if: always()
uses: actions/upload-artifact@v7
with:
name: test-core-run-summary-${{ matrix.shard }}-of-6
path: .turbo/runs/
if-no-files-found: ignore
retention-days: 1
# Runs even when the suite failed — that is when it earns its keep. It
# answers TWO questions about a red suite, and needs both to be able to
# say anything at all about a green one.
#
# 1. Was every test vitest COUNTED actually run (#3812)? A worker dying
# at the process level leaves a summary that still leads with
# "passed" while falling short of its own declared count — a red
# that READS like a pass.
# 2. Did every package scheduled on this shard report AT ALL (#10032)?
# Question 1 is answered from summary lines PRESENT in the log, so a
# package that printed nothing contributes no row: neither counted
# nor missed, simply invisible. That is why this step is handed the
# shard's scheduled package list (and the `turbo ls` document it was
# sharded from, which resolves each name to a directory) instead of
# grading the log against itself.
#
# ⛔ THE OLD NOTE HERE WAS WRONG AND COST A REAL TRIAGE. It read "a red
# suite plus a GREEN completeness check means real test failures". That
# inference does not hold for case 2, and #10032 is the bill: Test Core
# (2/3) failed naming @objectstack/example-showcase#test while this guard
# printed OK, because the failing package was the one that printed
# nothing — its complete 5083-line job log mentions that package twice,
# both times in the error summary, with no vitest output anywhere.
#
# ⛔ AND THE REPLACEMENT WAS STILL BLIND, FOR A SECOND REASON — measured
# on run 32391924279 (`Test Core (3/3)`, job 96499888763, complete 31839-
# line log). Under grouped log order turbo folds each task into a
# collapsible `::group::` EXCEPT the one it surfaces as the run's failure,
# which gets a bare `<pkg>:test` header instead. The guard read groups and
# prefixes but not that third shape, so the FAILING package — the one it
# exists to grade — was the one package whose summary it could not
# attribute, and its "refuse to guess" backstop then withheld judgement on
# every candidate and exited 0. It printed `OK (24 of 29 …)` under a note
# naming @objectstack/example-showcase as ungraded: the same green-that-
# reads-like-a-clearance, one turn of the screw further out.
# ⇒ The guard now knows all three header shapes, a package named in
# turbo's `Failed:` roster is judged even when the log holds strays it
# cannot place, and an ungraded package downgrades the verdict word from
# OK to PARTIAL and raises a ::warning:: annotation. Only with all three
# does green cover both questions — which is what makes the dichotomy
# above true. ⚠ A red HERE can never turn a green shard red: it needs
# turbo to have named a failed task, and that already failed the run.
#
# ⚠ The guard does NOT charge a silent package to this shard when turbo
# simply never reached it — turbo stops scheduling on the first failure,
# so an ordinary red suite leaves later packages unrun. Those are printed
# as notes. See the script header for the two exemptions and the two
# rules that decide red.
#
# ⚠ NOT diagnosed, by either pass: the original zero-output event itself.
# It has never been reproduced. The 2026-08-20 recurrence was NOT one —
# that failing suite streamed ~940 lines and a complete summary
# (`Test Files 21 passed (21)` / `Tests 342 passed (342)`), and failed on
# a vitest teardown error, not on silence. Read a "zero output" claim as
# a hypothesis until someone has grepped the WHOLE log for it.
- name: Test completeness guard
if: always()
run: |
if [ ! -f "$RUNNER_TEMP/test-core.log" ]; then
echo "No test log — the test step did not get far enough to produce one."
exit 0
fi
node scripts/check-test-completeness.mjs "$RUNNER_TEMP/test-core.log" \
--scheduled "$RUNNER_TEMP/shard-packages.txt" \
--package-list "$RUNNER_TEMP/turbo-ls.json"
# A stall's full diagnostic reports (JS stacks, libuv handles, heap
# summary per process) outlive the in-log digest — keep them so a #4250
# occurrence can be dissected offline. Free when nothing stalled: the
# directory is empty and if-no-files-found skips the upload. Shard-scoped
# name so the two matrix jobs don't collide.
- name: Upload stall diagnostic reports
if: failure()
uses: actions/upload-artifact@v7
with:
name: stall-reports-test-core-${{ matrix.shard }}
path: ${{ runner.temp }}/stall-reports/
if-no-files-found: ignore
retention-days: 14
# Seed the shared Turbo cache from main only (see the restore step
# above). always(): keep the seed fresh even when a test fails, matching
# the old actions/cache post-step behavior. Shard-scoped key so the two
# matrix jobs don't collide.
- name: Save Turbo cache (main only)
if: always() && github.event_name == 'push'
uses: actions/cache/save@v6
with:
path: .turbo/cache
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ matrix.shard }}-${{ github.ref_name }}-${{ github.sha }}
# ── This shard's positive attestation (#6082) ─────────────────────────
# The credential test-gate counts: "shard N of 6 ran, and every step of it
# passed". These two steps are LAST and carry NO `if:` — that is what
# makes the statement true, because an unguarded step runs only when every
# preceding step of the job succeeded, and nothing after them can fail the
# job while the credential already counts as a pass.
# check:shard-attestation enforces both properties statically, so a step
# appended below here, or an `if:` added to either, is a red lint job
# rather than a silently weakened credential.
- name: Attest this shard ran and passed
run: |
node scripts/check-shard-attestation.mjs --emit \
--job test --shard ${{ matrix.shard }} --total 6 \
--out "$RUNNER_TEMP/shard-attestation"
- name: Publish this shard's attestation
uses: actions/upload-artifact@v7
with:
name: shard-attest-test-${{ matrix.shard }}-of-6
path: ${{ runner.temp }}/shard-attestation/
if-no-files-found: error
retention-days: 1
overwrite: true
test-gate:
# Stable required-check name for the sharded Test Core matrix — the exact
# contract dogfood-gate documents below (#3622): branch protection requires
# the bare "Test Core" context, and once the job is a matrix that context
# can never appear again, deadlocking every PR. Keeping the contract HERE
# means a future shard-count change cannot deadlock the repo. `name:` and
# `if: always()` are therefore both load-bearing: this must not become
# `if: !cancelled()` (see dogfood-gate), and it must not be renamed.
# Both halves are now asserted rather than only written down: the `name:`
# literal by `check:required-contexts` (#6865), the `if: always()` and the
# attestation roster by `check:shard-attestation` (#6082).
#
# ── It COUNTS credentials; it does not read one aggregate word (#6082) ──
#
# This gate used to decide from `needs.test.result` alone, passing on
# `success|skipped|cancelled` and failing on everything else. One datum
# cannot carry three shards' verdicts, and two measured defects followed:
#
# - run 31120902911: the queue discarded shards under runner starvation
# (runner_id 0, no `steps`, zero tests executed), the aggregate read the
# undocumented `abandoned`, the `*)` fallthrough painted red, and #6010
# was evicted 31 seconds later. Whitelisting `abandoned` was REJECTED
# (maintainer, 2026-08-07): that run was NOT moot — the queue was still
# consuming its verdicts — so passing it would publish `Test Core:
# success` over zero test runs, exactly what #4928 blocks.
# - run 31114735713: shard `Test Core (3/3)` concluded `failure` while
# this gate's read of the same matrix was `abandoned`. A lifecycle value
# in the aggregate SWALLOWS whatever the siblings concluded — so the
# bug was never really about one missing word.
#
# So the verdict is now: every shard the matrix DECLARES must publish an
# "I ran and passed" artifact, and the gate must count all of them. A shard
# that was never scheduled publishes nothing and cannot be counted; a shard
# that failed publishes nothing either. `cancelled` (#3668) and
# filter-`skipped` (#4928) keep their existing meanings — see the script.
name: Test Core
needs: [test, filter]
# ── ROSTER DECLARATION (#10490) ───────────────────────────────────────
# What this required aggregate stands in for, stated rather than inferred.
# `node scripts/check-aggregator-roster.mjs` asserts MEMBERS + NON_MEMBERS
# equals `needs:` EXACTLY, in both directions, so a lane added to this
# workflow and forgotten here cannot ride green behind "Test Core" as an
# advisory check-run, and a `needs:` entry whose job was deleted cannot
# keep being claimed. `filter` is a non-member on purpose: it is consulted
# for the #4928 skipped-only-when-filter-succeeded guard, not aggregated —
# and having somewhere to SAY that is what stops the roster from being
# widened to launder a red. The members are cross-checked against the
# `--leg` tokens below, so this declaration cannot drift into a second
# source of truth for what check:shard-attestation counts.
env:
OS_AGGREGATOR_MEMBERS: test
OS_AGGREGATOR_NON_MEMBERS: filter
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
# Same-run artifact download needs no permission beyond the `contents:
# read` above (`actions: read` is for cross-run/cross-repo only), so this
# gate's permission block is unchanged. continue-on-error: a legitimate
# filter-skipped run has zero artifacts to match, and an artifact-service
# fault must reach the verdict as "credentials missing" — fail-closed,
# with the step's own outcome printed — instead of an opaque red.
- name: Download test shard attestations
id: attestations
continue-on-error: true
uses: actions/download-artifact@v8
with:
pattern: shard-attest-test-*
path: ${{ runner.temp }}/shard-attestations
merge-multiple: true
- name: Verify test shard results
env:
OS_ATTEST_DIR: ${{ runner.temp }}/shard-attestations
OS_TEST_RESULT: ${{ needs.test.result }}
OS_FILTER_RESULT: ${{ needs.filter.result }}
OS_DOWNLOAD_OUTCOME: ${{ steps.attestations.outcome }}
run: |
node scripts/check-shard-attestation.mjs --verify \
--gate 'Test Core' \
--dir "$OS_ATTEST_DIR" \
--filter-result "$OS_FILTER_RESULT" \
--download-outcome "$OS_DOWNLOAD_OUTCOME" \
--leg "test/6:$OS_TEST_RESULT"
# ── Temporal conformance against live, non-UTC servers (ADR-0053 D-A3) ─────
#
# The datetime storage work (#3912/#3942) was verified against real servers
# because every one of its bugs was invisible on all-UTC infrastructure: a
# zone-naive write resolved in the SERVER's zone on Postgres, mysql2 rendered
# a Date in the HOST's zone, and a bare YYYY-MM-DD comparand meant a
# different midnight per dialect. The committed suites are opt-in
# (OS_TEST_POSTGRES_URL / OS_TEST_MYSQL_URL) and skip without a server, so
# without this job they would never run in CI and the seam could regress
# silently — D-A3's exact concern.
#
# Every timezone here is deliberately DIFFERENT: servers at +08:00, the Node
# process at America/New_York, assertions in UTC. Both suites assert they
# are pointed at a non-UTC server, so a mis-provisioned service fails loudly
# instead of letting the job pass vacuously — and each test step now asserts
# the PROCESS zone too, which nothing did before: a dropped `TZ:` line
# silently returned the whole job to UTC coverage with everything still
# green.
#
# The job also carries the non-SQL half of that axis (core, formula,
# driver-memory, driver-mongodb, service-analytics) — the other backends the
# temporal conformance matrix holds to one standard. Its NAME still says
# "live PG + MySQL" on purpose: the name IS the required check, so renaming
# it would silently drop the gate wherever branch protection lists it — the
# same trap the dogfood shards note below. `check:required-contexts` pins
# this literal, parenthetical and all (#6865).
temporal-conformance:
name: Temporal Conformance (live PG + MySQL)
needs: filter
# See THE FILTER CONTRACT on the filter job's outputs (#4928).
if: ${{ !cancelled() && needs.filter.outputs.core != 'false' }}
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U postgres"
--health-interval=5s
--health-timeout=5s
--health-retries=12
mysql:
# Real MySQL 8.0. The hands-on verification of #3942 ran on MariaDB
# 10.11 — the stricter dialect for datetime literals — so this job is
# the other half of the compatibility claim. `-h 127.0.0.1` forces the
# ping over TCP: the image's init phase runs mysqld with networking
# disabled, so a socket ping would report healthy before init finishes.
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: conformance
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot"
--health-interval=5s
--health-timeout=5s
--health-retries=24
steps:
- name: Checkout repository
uses: actions/checkout@v7
# Service containers cannot override the image command, so the non-UTC
# zones are set post-start through each server's own mechanism. Echoed
# back so a provisioning failure is visible in the log — though the
# suites' own non-UTC guards are the real gate.
- name: Point both servers at a non-UTC timezone
run: |
docker exec ${{ job.services.postgres.id }} psql -U postgres -c "ALTER SYSTEM SET timezone='Asia/Shanghai'"
docker exec ${{ job.services.postgres.id }} psql -U postgres -c "SELECT pg_reload_conf()"
docker exec ${{ job.services.postgres.id }} psql -U postgres -tAc "SHOW timezone"
docker exec ${{ job.services.mysql.id }} mysql -uroot -proot -e "SET GLOBAL time_zone = '+08:00'"
docker exec ${{ job.services.mysql.id }} mysql -uroot -proot -N -e "SELECT @@global.time_zone"
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Setup pnpm
uses: ./.github/actions/setup-pnpm
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v6
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-v3-
# Restore-only (same policy as every other job); falls back to the Build
# Core namespace because that job builds every package (a superset of the
# build closure this one needs) and its cache is seeded from main. It
# used to fall back to Test Core, but that namespace is per-shard now and
# neither single shard builds a superset.
- name: Restore Turbo cache
uses: actions/cache/restore@v6
with:
path: .turbo/cache
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-${{ github.job }}-
${{ runner.os }}-turbo-build-core-${{ github.ref_name }}-
${{ runner.os }}-turbo-build-core-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build driver-sql and its dependencies
run: pnpm exec turbo run build --filter=@objectstack/driver-sql... --concurrency=4
# The whole driver-sql suite runs under the skewed process zone — not
# just the live-server files — so a TZ-sensitive assumption anywhere in
# the driver's tests fails here before it can ship.
#
# run-with-stall-guard: same wiring as Test Core (see the comment there;
# #4250). Both of this job's test legs have hit the same nondeterministic
# stall (#4331) — once frozen mid-file in core's kernel.test, once silent
# for 24 minutes AFTER every suite printed `Done` (a process that never
# exited) — and each burned the full 30-minute job timeout to end as an
# uninformative "The operation was canceled". The guard watches output
# silence, so both shapes become a labeled red naming the last line after
# 10 quiet minutes, and its process-group kill takes down whatever
# refused to exit. `--log` is the guard's own tee (mandatory); no
# completeness guard reads these files yet. The zone-assert `node -e`
# stays outside the wrapper: a one-shot print cannot stall, and the
# guard should time the suite only.
- name: Run driver-sql suite against both live servers
env:
TZ: America/New_York
OS_TEST_POSTGRES_URL: postgres://postgres:postgres@127.0.0.1:5432/postgres
OS_TEST_MYSQL_URL: mysql://root:root@127.0.0.1:3306/conformance
# The URL half of the same vacuous-pass hole the `node -e` below closes
# for the zone (#4245). The temporal conformance MATRIX now runs one
# cell per dialect (ADR-0053 D-A3 `driver {SQLite, Postgres at
# minimum}`), and a cell whose URL is missing is an ordinary skip — so
# losing either of the two lines above would silently return the matrix
# to SQLite-only coverage and stay green. This flag says "this runner
# provisioned the servers", which turns that skip into a named red.
OS_EXPECT_LIVE_DIALECT_MATRIX: '1'
run: |
# The axis is only real if the zone actually took. Without this, a
# dropped `TZ:` line silently returns the job to UTC coverage and
# everything still passes — the same vacuous-pass hole the live-server
# suites close by asserting a non-UTC SERVER.
node -e "const tz=Intl.DateTimeFormat().resolvedOptions().timeZone,off=new Date().getTimezoneOffset();if(!tz||tz==='UTC'||off===0){console.error('process zone is '+tz+' (offset '+off+') — this job must run skewed');process.exit(1)}console.log('process zone: '+tz+' (offset '+off+')')"
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/temporal-driver-sql.log" --stall-minutes 10 -- \
pnpm --filter @objectstack/driver-sql test
# The non-SQL half of the same axis. `driver-sql` has run under a skewed
# process zone since #3979, but the other backends the temporal
# conformance matrix holds to that standard never did — and they are the
# ones whose correctness rests ENTIRELY on process-zone independence,
# since they have no server to blame: `storageDatetimeValue` /
# `storageTimeValue` fold an instant through UTC getters, the filter-token
# resolver derives "today" from UTC calendar parts, and the analytics
# bucketer does the same. Swap one `getUTC*` for its local twin and every
# one of those silently shifts by the host's offset — the exact defect
# ADR-0053 D-C1 records for a `Date` bound to a Postgres TIME column.
#
# Measured before adding this: all five suites already pass under
# America/New_York (-5/-4, with DST), Asia/Kolkata (+05:30) and
# Pacific/Chatham (+12:45). So this found nothing on the day it landed,
# which is what a ratchet is for — it makes the property enforced rather
# than incidental.
- name: Build the non-SQL temporal backends
run: >-
pnpm exec turbo run build
--filter=@objectstack/service-analytics...
--filter=@objectstack/driver-memory...
--filter=@objectstack/driver-mongodb...
--filter=@objectstack/formula...
--concurrency=4
- name: Run the non-SQL temporal backends under the skewed process zone
env:
TZ: America/New_York
run: |