Repository navigation
1116 lines (1035 loc) · 54.4 KB
/
Copy pathci.yml
File metadata and controls
1116 lines (1035 loc) · 54.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
name: CI
on:
workflow_dispatch:
push:
branches: [main]
pull_request:
branches: [main]
# Without this, every push left the previous run of this workflow burning a
# runner: three beta-acceptance jobs ran concurrently on superseded commits and
# starved each other, so the oldest reached its 120-minute timeout without
# finishing. Superseded PR runs are cancelled; main is never cancelled, because
# its runs gate the release.
#
# Because main's runs queue one at a time, every job carries `timeout-minutes`
# (about 2-3x its longest green run): without one, a hung job holds the queue
# for GitHub's 6-hour default. On 2026-10-16 storybook-test hung twice and held
# every later merge and deploy until it was cancelled by hand.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
permissions:
contents: read
pull-requests: read
checks: write
security-events: write
jobs:
# ~5.3m measured: independent source and policy gates need no services.
static:
runs-on: ubuntu-latest
timeout-minutes: 20 # longest green run ~8 min
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# BQC-6.9: the changed-code budget diffs added src files against
# origin/main — the check job needs full history.
fetch-depth: 0
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
# version intentionally unset: package.json packageManager (pnpm@10.6.5) is the single source
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
# BQC-7.7: supply-chain gates — hard failures (no continue-on-error).
# Thresholds + exception policy: docs/operations/security-ci-policy.md
- name: Dependency vulnerability audit
run: pnpm check:dependency-audit
- name: License policy
run: pnpm check:licenses
- name: Action/image pin policy
run: pnpm check:action-pins
- name: Container image inventory and coverage policy
run: pnpm check:container-images
- name: Format check
run: pnpm format:check
- name: Typecheck
run: pnpm typecheck
- name: Lint
run: pnpm lint:ci
# Fast unit feedback is split by Vitest's file-balanced native shard. Each
# matrix row reports its own failing files while all four run concurrently.
test-unit:
name: test-unit (${{ matrix.shard }}/4)
runs-on: ubuntu-latest
timeout-minutes: 10 # longest green run ~3 min
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
# version intentionally unset: package.json packageManager (pnpm@10.6.5) is the single source
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Test unit shard ${{ matrix.shard }}/4
env:
NODE_ENV: test
DATABASE_URL: postgresql://test:test@localhost:5432/test
REDIS_URL: redis://localhost:6379
BETTER_AUTH_SECRET: test-secret-at-least-32-characters-long-for-ci
BETTER_AUTH_URL: http://localhost:3000
RESEND_API_KEY: re_ci_test_key_placeholder
GOOGLE_CLIENT_ID: ci-placeholder-client-id
GOOGLE_CLIENT_SECRET: ci-placeholder-client-secret
ENCRYPTION_KEY: aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd
OAUTH_STATE_SECRET: aabbccddaabbccddaabbccddaabbccdd
run: pnpm exec vitest run --project=unit --shard=${{ matrix.shard }}/4
# ~4.6m measured test work plus migration gates: integration owns the shared
# scratch database; vitest.config.ts explicitly marks unit DB/Redis-free.
test-integration:
runs-on: ubuntu-latest
timeout-minutes: 25 # longest green run ~10 min
services:
postgres:
# BQC-7.7: digest-pinned (multi-arch index) — tag comment for humans.
image: postgres:16@sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20 # 16.14
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
# BQC-7.7: digest-pinned (multi-arch index) — tag comment for humans.
image: redis:7@sha256:595cc6f2bb3af6e03347b90deb6123c6aa2c81dea05ce08128de8a174b6ac67b # 7.4.10
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# BQC-6.9: the changed-code budget diffs added src files against
# origin/main — the check job needs full history.
fetch-depth: 0
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
# version intentionally unset: package.json packageManager (pnpm@10.6.5) is the single source
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Run migrations
env:
NODE_ENV: test
DATABASE_URL: postgresql://test:test@localhost:5432/test
BETTER_AUTH_SECRET: test-secret-at-least-32-characters-long-for-ci
BETTER_AUTH_URL: http://localhost:3000
GOOGLE_CLIENT_ID: ci-placeholder-client-id
GOOGLE_CLIENT_SECRET: ci-placeholder-client-secret
ENCRYPTION_KEY: aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd
OAUTH_STATE_SECRET: aabbccddaabbccddaabbccddaabbccdd
# BQC-5.4: deploy apply order — Better Auth, then the Drizzle
# journal — so the tested DB matches deploy state.
run: |
pnpm auth:migrate
pnpm db:migrate
# BQC-7.1: predeploy migration parity — the deploy runner
# (scripts/migrate-deploy.ts, the Railway preDeployCommand) must
# converge instantly against the DB the manual sequence just migrated.
# Exit 0 = the deploy path and the documented manual path reach the
# same end state; guards drift between the two on every PR.
- name: Predeploy migration parity
env:
NODE_ENV: test
DEPLOY_MIGRATE: '1'
DATABASE_URL: postgresql://test:test@localhost:5432/test
BETTER_AUTH_SECRET: test-secret-at-least-32-characters-long-for-ci
BETTER_AUTH_URL: http://localhost:3000
GOOGLE_CLIENT_ID: ci-placeholder-client-id
GOOGLE_CLIENT_SECRET: ci-placeholder-client-secret
ENCRYPTION_KEY: aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd
OAUTH_STATE_SECRET: aabbccddaabbccddaabbccddaabbccdd
# One-run sealed migrator key. CI migrates an empty database, so the
# provider-subject inventory has no seeded row and the initializer
# fails closed (provider_subject_key_initialization_invalid) unless a
# migrator key is injected exactly once — the same contract production
# follows, with the key removed before normal services start.
REVIEW_PROVIDER_SUBJECT_HMAC_MIGRATOR_KEYS: 'v1:cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd'
run: pnpm db:migrate-deploy
# BQC-7.7: migration artifact consistency as a NAMED gate — the semantic
# model↔catalog comparator (src/shared/db/schema-drift.ts) against the DB
# the migration steps just brought to deploy state.
- name: Schema drift (model ↔ migrated catalog)
env:
NODE_ENV: test
DATABASE_URL: postgresql://test:test@localhost:5432/test
BETTER_AUTH_SECRET: test-secret-at-least-32-characters-long-for-ci
BETTER_AUTH_URL: http://localhost:3000
GOOGLE_CLIENT_ID: ci-placeholder-client-id
GOOGLE_CLIENT_SECRET: ci-placeholder-client-secret
ENCRYPTION_KEY: aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd
OAUTH_STATE_SECRET: aabbccddaabbccddaabbccddaabbccdd
run: pnpm check:schema-drift
# Pull requests and main run the identical test pair: the unit project
# in the four test-unit shards above, the integration project here.
# Neither runs under coverage and no coverage floor is enforced: the
# BQC-6.9 `check:coverage` ratchet was deleted on 2026-09-05.
- name: Test
env:
NODE_ENV: test
DATABASE_URL: postgresql://test:test@localhost:5432/test
REDIS_URL: redis://localhost:6379
# This job has the Redis service above, so the Redis-backed suites
# must run. Without this an unanswered probe makes them return before
# their first assertion and report green
# (src/shared/testing/redis-test-lease.ts). Only a job with a Redis
# service may set it; redis-test-lease.test.ts pins both halves.
REQUIRE_TEST_REDIS: '1'
BETTER_AUTH_SECRET: test-secret-at-least-32-characters-long-for-ci
BETTER_AUTH_URL: http://localhost:3000
RESEND_API_KEY: re_ci_test_key_placeholder
GOOGLE_CLIENT_ID: ci-placeholder-client-id
GOOGLE_CLIENT_SECRET: ci-placeholder-client-secret
ENCRYPTION_KEY: aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd
OAUTH_STATE_SECRET: aabbccddaabbccddaabbccddaabbccdd
run: pnpm test:integration
# Build output and every consumer/boundary stay in one chain.
# The booted security-header probe needs Redis, but no PostgreSQL.
artifacts:
runs-on: ubuntu-latest
timeout-minutes: 10 # longest green run ~2 min
services:
redis:
# BQC-7.7: digest-pinned (multi-arch index) — tag comment for humans.
image: redis:7@sha256:595cc6f2bb3af6e03347b90deb6123c6aa2c81dea05ce08128de8a174b6ac67b # 7.4.10
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# BQC-6.9: the changed-code budget diffs added src files against
# origin/main — the check job needs full history.
fetch-depth: 0
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
# version intentionally unset: package.json packageManager (pnpm@10.6.5) is the single source
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
# BQC-6.9: changed-code budget — every src production module added by the
# branch (API routes included; pages and components are not) must be
# imported by a test, or by a module a test imports (diffs against
# origin/main; the checkout above uses fetch-depth: 0). That proves a test
# can load the module, not that one asserts anything about it, and it is
# still the only check on new production code landing untested. Measured
# 2-3s. See scripts/check-changed-code.mjs.
- name: Changed-code budget
run: pnpm check:changed-code
- name: Web build
run: pnpm build
env:
NODE_ENV: production
DATABASE_URL: postgresql://test:test@localhost:5432/test
BETTER_AUTH_SECRET: test-secret-at-least-32-characters-long-for-ci
BETTER_AUTH_URL: http://localhost:3000
GOOGLE_CLIENT_ID: ci-placeholder-client-id
GOOGLE_CLIENT_SECRET: ci-placeholder-client-secret
ENCRYPTION_KEY: aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd
OAUTH_STATE_SECRET: aabbccddaabbccddaabbccddaabbccdd
# BQC-7.6 / STD-P1-07: booted-artifact security gate — boots the built
# server and asserts the full B0.7 header set on 200 AND 404 responses
# (plus x-request-id and the 413 body limit). The script generates its
# own per-run secrets; no placeholder env is passed to the boot.
- name: Security headers (booted artifact)
run: pnpm check:security-headers
# Built-artifact gate for the pinned reply-language runtime: cld3-asm must
# stay external and still load + detect from inside .output/server. When
# it gets bundled, its emscripten loader throws `runtimeModule is not a
# function` and every reply suggestion 500s — invisible to the unit suite,
# which injects a fake detector.
- name: Reply-language runtime (built artifact)
run: pnpm check:language-verifier
# BQC-6.8: client bundle budgets on the production output (main entry /
# initial payload / lazy chunks — see scripts/check-bundle-budget.mjs).
- name: Bundle budget
run: pnpm check:bundles
# The worker build is part of `pnpm build`; the single tsup invocation
# emits the web preload and worker/migration bundles.
# The forbidden-source rules read source maps and nothing else, so the
# two places that must carry maps are named: without them a build that
# stopped emitting maps would pass with nothing inspected. This step is
# the only run that reads the client maps: the image build's may already
# be deleted by the Sentry upload.
- name: Production artifact boundary
run: >-
pnpm check:production-artifacts
--require-source-maps .output/client-source-maps
--require-source-maps dist-worker
.output
dist-worker
# BQC-7.1: artifact digest — deterministic tarball of the deploy
# artifacts (sorted names, zeroed mtimes/owners, gzip -n), digest in
# the step output and job summary as build evidence.
- name: Artifact digest
run: |
tar --sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner -cf - \
.output dist-worker \
| gzip -n > /tmp/artifact.tgz
DIGEST=$(shasum -a 256 /tmp/artifact.tgz | cut -d' ' -f1)
echo "sha256:$DIGEST (all deploy artifacts)"
{
echo "### Build artifact digest"
echo "\`sha256:$DIGEST\` — all deploy artifacts (deterministic tar)"
} >> "$GITHUB_STEP_SUMMARY"
# BQC-7.1: source SBOM (SPDX JSON) retained as a build artifact.
- name: Generate SBOM
uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
with:
format: spdx-json
output-file: sbom.spdx.json
- name: Upload SBOM
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sbom-spdx
path: sbom.spdx.json
retention-days: 30
# Aggregate-only seam (no measured workload): retain the required status
# context without allowing a failed, cancelled, or skipped dependency to pass.
check:
needs: [static, test-unit, test-integration, artifacts]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5 # longest green run ~1 min
steps:
- name: Require every check shard to succeed
if: >-
contains(needs.*.result, 'failure') ||
contains(needs.*.result, 'cancelled') ||
contains(needs.*.result, 'skipped')
run: exit 1
# BQC-7.1: the production containers are the single deploy build path
# (nixpacks removed) — prove every classified image builds reproducibly on
# every PR and satisfies its explicit runtime contract.
#
# Two bounded groups keep the two heaviest web and worker builds concurrent.
# Every descriptor still owns its build, smoke, SBOM, and Grype evidence.
docker-images:
name: docker image group (${{ matrix.group }})
runs-on: ubuntu-latest
timeout-minutes: 25 # longest green run ~8 min
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- group: web
images: >-
[{"name":"web","dockerfile":"Dockerfile","tag":"repkey-web:ci","publish":true},{"name":"google-provider-redis","dockerfile":"Dockerfile.google-provider-redis","tag":"repkey-google-provider-redis:ci","publish":true}]
- group: worker-tools
images: >-
[{"name":"worker","dockerfile":"Dockerfile","tag":"repkey-worker:ci","publish":true},{"name":"perf-runner","dockerfile":"Dockerfile.perf-runner","tag":"repkey-perf-runner:ci","publish":false},{"name":"provider-sandbox","dockerfile":"Dockerfile.sandbox","tag":"repkey-provider-sandbox:ci","publish":false}]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Build grouped images
env:
IMAGE_GROUP_JSON: ${{ matrix.images }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
run: |
set -euo pipefail
jq -e 'type == "array" and length > 0 and length <= 4 and all(.[]; (.name | type == "string") and (.dockerfile | type == "string") and (.tag | type == "string") and (.publish | type == "boolean"))' <<<"$IMAGE_GROUP_JSON" >/dev/null
while IFS= read -r image; do
name=$(jq -r '.name' <<<"$image")
dockerfile=$(jq -r '.dockerfile' <<<"$image")
tag=$(jq -r '.tag' <<<"$image")
build_flags=(--build-arg "SOURCE_REVISION=$GITHUB_SHA")
if [[ "$name" == "web" ]]; then
# Org and project NAME the release and are repository variables,
# not secrets, so they stay build arguments. The upload TOKEN is a
# BuildKit secret: a build argument is recorded in image history,
# build provenance and the exported GHA layer cache, which would
# publish it to anyone who can pull or inspect the image.
# `check:container-images` fails the build if a secret-shaped
# value is passed as a build argument again.
build_flags+=(
--build-arg "SENTRY_ORG=$SENTRY_ORG"
--build-arg "SENTRY_PROJECT=$SENTRY_PROJECT"
)
# Absent on forks and on any run without the repository secret;
# the Dockerfile then skips the source-map upload instead of
# failing, so the image still builds.
if [[ -n "${SENTRY_AUTH_TOKEN:-}" ]]; then
build_flags+=(--secret "id=sentry_auth_token,env=SENTRY_AUTH_TOKEN")
fi
fi
docker buildx build \
--load \
--cache-from "type=gha,scope=ci-image-${name}" \
--cache-to "type=gha,mode=max,scope=ci-image-${name}" \
"${build_flags[@]}" \
-f "$dockerfile" \
-t "$tag" \
.
done < <(jq -c '.[]' <<<"$IMAGE_GROUP_JSON")
- name: Smoke images (contract, non-root, prod-deps purity)
env:
IMAGE_GROUP_JSON: ${{ matrix.images }}
run: |
set -euo pipefail
jq -e 'type == "array" and length > 0 and length <= 4' <<<"$IMAGE_GROUP_JSON" >/dev/null
while IFS= read -r image; do
IMAGE_NAME=$(jq -r '.name' <<<"$image")
IMAGE_TAG=$(jq -r '.tag' <<<"$image")
case "$IMAGE_NAME" in
web)
docker run --rm repkey-web:ci node -e "process.exit(0)"
test "$(docker image inspect repkey-web:ci --format '{{.Config.User}}')" = "node"
docker run --rm --entrypoint test repkey-web:ci -e /app/node_modules/pg
docker run --rm --entrypoint node repkey-web:ci -e "const p=require('/app/package.json');process.exit('scripts' in p?1:0)"
for sentinel in vitest typescript eslint prettier; do
if docker run --rm --entrypoint test repkey-web:ci -e "/app/node_modules/$sentinel"; then
echo "::error::devDependency $sentinel leaked into repkey-web:ci prod node_modules"
exit 1
fi
done
test "$(docker image inspect repkey-web:ci --format '{{index .Config.Labels "com.repkey.google-import-contract"}}')" = "final"
test "$(docker image inspect repkey-web:ci --format '{{index .Config.Labels "com.repkey.rollout-scope"}}')" = "serving-final"
docker run --rm --entrypoint sh repkey-web:ci -c \
"! find /app/.output /app/dist-worker -type f \( -name '*seed-e2e-user*' -o -name '*provision-google-admission-role*' \) -print | grep -q . && ! grep -R -l 'password123' /app/.output /app/dist-worker 2>/dev/null"
;;
worker)
docker run --rm repkey-worker:ci node -e "process.exit(0)"
# web and worker are ONE image now; the image CMD is the web
# server, so prove the worker start command's entrypoints are
# present (railway.worker.json `startCommand` runs them).
docker run --rm --entrypoint node repkey-worker:ci -e \
"require.resolve('/app/dist-worker/index.js');require.resolve('/app/dist-worker/worker-observability-preload.js')"
test "$(docker image inspect repkey-worker:ci --format '{{.Config.User}}')" = "node"
docker run --rm --entrypoint test repkey-worker:ci -e /app/node_modules/pg
docker run --rm --entrypoint node repkey-worker:ci -e "const p=require('/app/package.json');process.exit('scripts' in p?1:0)"
for sentinel in vitest typescript eslint prettier; do
if docker run --rm --entrypoint test repkey-worker:ci -e "/app/node_modules/$sentinel"; then
echo "::error::devDependency $sentinel leaked into repkey-worker:ci prod node_modules"
exit 1
fi
done
test "$(docker image inspect repkey-worker:ci --format '{{index .Config.Labels "com.repkey.google-import-contract"}}')" = "final"
test "$(docker image inspect repkey-worker:ci --format '{{index .Config.Labels "com.repkey.rollout-scope"}}')" = "serving-final"
docker run --rm --entrypoint sh repkey-worker:ci -c \
"! find /app/dist-worker -type f \( -name '*seed-e2e-user*' -o -name '*provision-google-admission-role*' \) -print | grep -q . && ! grep -R -l 'password123' /app/dist-worker 2>/dev/null"
;;
google-provider-redis)
sh scripts/ci/smoke-provider-redis-image.sh repkey-google-provider-redis:ci
;;
provider-sandbox)
docker run --rm --entrypoint sh repkey-provider-sandbox:ci -c 'set -eu; node provider-sandbox.mjs --kind=gbp --port=3000 >/tmp/provider-sandbox.log 2>&1 & child=$!; trap '\''kill "$child" 2>/dev/null || true; wait "$child" 2>/dev/null || true'\'' EXIT; attempt=0; until node -e "fetch('\''http://127.0.0.1:3000/__control/health'\'').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"; do attempt=$((attempt+1)); if [ "$attempt" -ge 30 ]; then cat /tmp/provider-sandbox.log; exit 1; fi; sleep 0.2; done'
test "$(docker image inspect repkey-provider-sandbox:ci --format '{{.Config.User}}')" = "node"
test "$(docker image inspect repkey-provider-sandbox:ci --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$GITHUB_SHA"
docker run --rm --entrypoint test repkey-provider-sandbox:ci ! -e /app/node_modules
docker run --rm --entrypoint test repkey-provider-sandbox:ci ! -e /usr/local/bin/npm
;;
perf-runner)
docker run --rm --entrypoint node repkey-perf-runner:ci dist-perf-runner/load-test.js --list
test "$(docker image inspect repkey-perf-runner:ci --format '{{.Config.User}}')" = "node"
test "$(docker image inspect repkey-perf-runner:ci --format '{{len .Config.Cmd}} {{join .Config.Cmd " "}}')" = "2 sleep infinity"
test "$(docker image inspect repkey-perf-runner:ci --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$GITHUB_SHA"
docker run --rm --entrypoint test repkey-perf-runner:ci ! -e /app/src
docker run --rm --entrypoint test repkey-perf-runner:ci ! -e /app/node_modules/tsx
docker run --rm --entrypoint test repkey-perf-runner:ci ! -e /usr/local/bin/npm
docker run --rm --entrypoint test repkey-perf-runner:ci -e /app/node_modules/pg
docker run --rm --entrypoint node repkey-perf-runner:ci -e "const p=require('/app/package.json');process.exit('scripts' in p?1:0)"
for entry in load-test.js seed-scale.js seed-fleet.js write-scale-evidence.js; do
docker run --rm --entrypoint test repkey-perf-runner:ci -e "/app/dist-perf-runner/$entry"
done
;;
*)
echo "::error::No smoke contract for matrix image $IMAGE_NAME"
exit 1
;;
esac
{
echo "### Governed image digest — $IMAGE_NAME"
echo "\`$IMAGE_TAG@$(docker image inspect "$IMAGE_TAG" --format '{{.Id}}')\`"
} >> "$GITHUB_STEP_SUMMARY"
done < <(jq -c '.[]' <<<"$IMAGE_GROUP_JSON")
# BQC-7.7: every built image gets an SPDX JSON inventory. Each bounded
# group stages its files under one unique artifact; the `docker`
# aggregate job still proves and publishes the exact five-file set.
- name: Install pinned Syft
id: syft
uses: anchore/sbom-action/download-syft@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
with:
syft-version: v1.42.3
- name: Generate grouped image SBOMs
env:
IMAGE_GROUP_JSON: ${{ matrix.images }}
SYFT_CMD: ${{ steps.syft.outputs.cmd }}
run: |
set -euo pipefail
while IFS= read -r image; do
name=$(jq -r '.name' <<<"$image")
tag=$(jq -r '.tag' <<<"$image")
"$SYFT_CMD" scan "$tag" -o "spdx-json=sbom-${name}.spdx.json"
done < <(jq -c '.[]' <<<"$IMAGE_GROUP_JSON")
- name: Stage grouped image SBOMs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sbom-image-group-${{ matrix.group }}-spdx
path: sbom-*.spdx.json
if-no-files-found: error
retention-days: 1
# BQC-7.7: hard fail on HIGH+ findings. Scanning each generated SBOM
# avoids re-cataloguing the image, and the explicit config keeps every
# reviewed exception in the fail-closed gate.
#
# Own the database cache and rotate its exact key once per UTC day; no
# restore prefix may fall back to a stale prior-day DB.
- name: Resolve Grype DB cache date
id: grype-db-date
run: echo "utc=$(date -u +%F)" >> "$GITHUB_OUTPUT"
- name: Restore daily Grype vulnerability DB
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/grype/db
key: grype-db-v0.116.1-${{ steps.grype-db-date.outputs.utc }}
- name: Install pinned Grype
id: grype
uses: anchore/scan-action/download-grype@27805bf3b4e84b4a5c980df22ed233c00390a439 # v7.4.2
with:
grype-version: v0.116.1
# The gate prints WHAT it found, not just that it found something.
#
# This step used to emit SARIF to a file that nothing uploaded and nothing
# read, so a failure said only `discovered vulnerabilities at or above the
# severity threshold` — no package, no CVE, no fixed-in version. On
# 2026-09-06 that cost a day: the gate went red on a vulnerability-database
# rollover, and the finding could not be named from CI at all, so the
# documented response (bump the base, or write an exception with an owner
# and a reason) could not be chosen on evidence. grype takes repeated `-o`,
# so the table costs one line and the SARIF is retained as an artifact for
# the runs where the table is not enough.
- name: Vulnerability scan grouped images (grype)
env:
GRYPE_CMD: ${{ steps.grype.outputs.cmd }}
IMAGE_GROUP_JSON: ${{ matrix.images }}
run: |
set -euo pipefail
while IFS= read -r image; do
name=$(jq -r '.name' <<<"$image")
"$GRYPE_CMD" \
-v \
-o "sarif=grype-${name}.sarif" \
-o table \
--fail-on high \
-c .grype.yaml \
"sbom:sbom-${name}.spdx.json"
done < <(jq -c '.[]' <<<"$IMAGE_GROUP_JSON")
- name: Upload vulnerability scan reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: grype-scan-${{ matrix.group }}
path: grype-*.sarif
if-no-files-found: warn
# Each group pushes its scanned bytes under a unique run/attempt staging
# tag. The source-SHA tags are created only by the aggregate after both
# groups pass, preventing a failed row from stranding a partial set.
- name: Authenticate production image stager
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
GHCR_TOKEN: ${{ github.token }}
run: echo "$GHCR_TOKEN" | docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
- name: Stage scanned production images
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
IMAGE_GROUP_JSON: ${{ matrix.images }}
run: |
set -euo pipefail
mkdir -p staged-image-digests
owner=$(tr '[:upper:]' '[:lower:]' <<<"$GITHUB_REPOSITORY_OWNER")
while IFS= read -r image; do
[[ "$(jq -r '.publish' <<<"$image")" == "true" ]] || continue
IMAGE_NAME=$(jq -r '.name' <<<"$image")
IMAGE_TAG=$(jq -r '.tag' <<<"$image")
repository="ghcr.io/${owner}/repkey-${IMAGE_NAME}"
staged_reference="${repository}:ci-run-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
if docker buildx imagetools inspect "$staged_reference" >/dev/null 2>&1; then
echo "::error::run-scoped staging tag already exists: $staged_reference"
exit 1
fi
test "$(docker image inspect "$IMAGE_TAG" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$GITHUB_SHA"
docker tag "$IMAGE_TAG" "$staged_reference"
docker push "$staged_reference"
digest=$(docker buildx imagetools inspect "$staged_reference" --format '{{json .Manifest.Digest}}' | tr -d '"')
[[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]
jq -S -n \
--arg image "$IMAGE_NAME" \
--arg repository "$repository" \
--arg stagedReference "$staged_reference" \
--arg digest "$digest" \
--arg sourceRevision "$GITHUB_SHA" \
'{image:$image,repository:$repository,stagedReference:$stagedReference,digest:$digest,sourceRevision:$sourceRevision}' \
> "staged-image-digests/${IMAGE_NAME}.json"
done < <(jq -c '.[]' <<<"$IMAGE_GROUP_JSON")
- name: Stage scanned image descriptors
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ci-image-digests-${{ matrix.group }}-${{ github.sha }}
path: staged-image-digests/*.json
if-no-files-found: error
retention-days: 90
# Keep the protected `docker` context stable while two measured image groups
# fan out above. The final artifacts are assembled here because upload-artifact
# v4+ forbids concurrent jobs mutating one shared artifact.
docker:
needs: [docker-images]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10 # longest green run ~1 min
permissions:
contents: read
packages: write
steps:
- name: Require every image matrix entry to succeed
if: needs.docker-images.result != 'success'
run: exit 1
- name: Download staged image SBOMs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: sbom-image-group-*-spdx
path: image-sboms
merge-multiple: true
- name: Require all five image SBOMs
run: test "$(find image-sboms -maxdepth 1 -type f -name 'sbom-*.spdx.json' | wc -l)" -eq 5
- name: Upload image SBOMs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sbom-images-spdx
path: image-sboms/sbom-*.spdx.json
if-no-files-found: error
retention-days: 30
- name: Download staged production image descriptors
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: ci-image-digests-*-${{ github.sha }}
path: staged-image-digests
merge-multiple: true
- name: Validate complete staged production image set
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
set -euo pipefail
test "$(find staged-image-digests -maxdepth 1 -type f -name '*.json' | wc -l)" -eq 3
jq -sS \
--arg revision "$GITHUB_SHA" \
--arg runId "$GITHUB_RUN_ID" \
--arg runAttempt "$GITHUB_RUN_ATTEMPT" \
'sort_by(.image) as $entries
| ["google-provider-redis","web","worker"] as $expected
| ($entries | map(.image)) as $observed
| if $observed != $expected then error("staged image set is incomplete or unexpected") else . end
| if any($entries[];
.sourceRevision != $revision
or (.digest | test("^sha256:[0-9a-f]{64}$") | not)
or .repository != ("ghcr.io/kodes-agency/repkey-" + .image)
or .stagedReference != (.repository + ":ci-run-" + $runId + "-" + $runAttempt)
)
then error("staged image repository, digest, source revision, or run reference is invalid")
else $entries
end' \
staged-image-digests/*.json > validated-image-digests.json
- name: Authenticate production image promoter
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
GHCR_TOKEN: ${{ github.token }}
run: echo "$GHCR_TOKEN" | docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
- name: Promote complete production image set
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
set -euo pipefail
# Preflight every staged digest and every existing immutable tag
# before creating any source-SHA tag.
while IFS= read -r image; do
repository=$(jq -r '.repository' <<<"$image")
staged_reference=$(jq -r '.stagedReference' <<<"$image")
expected_digest=$(jq -r '.digest' <<<"$image")
staged_digest=$(docker buildx imagetools inspect "$staged_reference" --format '{{json .Manifest.Digest}}' | tr -d '"')
test "$staged_digest" = "$expected_digest"
final_reference="${repository}:${GITHUB_SHA}"
if docker buildx imagetools inspect "$final_reference" >/dev/null 2>&1; then
final_digest=$(docker buildx imagetools inspect "$final_reference" --format '{{json .Manifest.Digest}}' | tr -d '"')
if [[ "$final_digest" != "$expected_digest" ]]; then
echo "::error::immutable image tag has a conflicting digest: $final_reference"
exit 1
fi
fi
done < <(jq -c '.[]' validated-image-digests.json)
while IFS= read -r image; do
repository=$(jq -r '.repository' <<<"$image")
expected_digest=$(jq -r '.digest' <<<"$image")
final_reference="${repository}:${GITHUB_SHA}"
if ! docker buildx imagetools inspect "$final_reference" >/dev/null 2>&1; then
docker buildx imagetools create \
--prefer-index=false \
--tag "$final_reference" \
"${repository}@${expected_digest}"
fi
done < <(jq -c '.[]' validated-image-digests.json)
# The release map is written only after all three final tags resolve
# to the staged digests.
while IFS= read -r image; do
repository=$(jq -r '.repository' <<<"$image")
expected_digest=$(jq -r '.digest' <<<"$image")
final_reference="${repository}:${GITHUB_SHA}"
final_digest=$(docker buildx imagetools inspect "$final_reference" --format '{{json .Manifest.Digest}}' | tr -d '"')
test "$final_digest" = "$expected_digest"
done < <(jq -c '.[]' validated-image-digests.json)
jq -S \
--arg revision "$GITHUB_SHA" \
--arg repository "$GITHUB_REPOSITORY" \
'{
version:"repkey-ci-image-digest-map-1",
sourceRevision:$revision,
source:{
repository:$repository,
ref:"refs/heads/main",
workflow:".github/workflows/ci.yml",
runId:env.GITHUB_RUN_ID,
runAttempt:(env.GITHUB_RUN_ATTEMPT | tonumber)
},
images:(map({key:.image,value:{repository:.repository,digest:.digest,sourceRevision:.sourceRevision}}) | from_entries)
}' \
validated-image-digests.json > ci-image-digest-map.json
- name: Upload production image digest map
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ci-image-digest-map-${{ github.sha }}
path: ci-image-digest-map.json
if-no-files-found: error
retention-days: 90
# BQC-7.7: secret scanning incl. full git history (gitleaks). Separate job:
# runs in parallel with check (no pnpm install needed) and its failure is a
# distinct required check. gitleaks-action is deliberately NOT used (org
# license requirement) — the official image is pinned by digest instead.
secrets:
runs-on: ubuntu-latest
timeout-minutes: 10 # longest green run ~1 min
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Full history — gitleaks scans every commit, not just the PR diff.
fetch-depth: 0
- name: Gitleaks
# BQC-7.7: PRs scan the PR range (sub-second-to-minutes); pushes to
# main scan ALL refs — the full-history net (measured 8m25s for 1960
# commits / 7.7 GB locally, acceptable on the merge path; ~2 min per
# PR is not achievable with this history size, hence the split — see
# docs/operations/security-ci-policy.md).
# --config: committed .gitleaks.toml (performance allowlist for the
# committed binary caches — see its header; NOT finding suppression).
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
OPTS="origin/${{ github.base_ref }}..HEAD"
else
OPTS="--all"
fi
echo "gitleaks log-opts: $OPTS"
docker run --rm -v "$PWD:/repo" \
zricethezav/gitleaks:v8.30.1@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f \
git /repo --config /repo/.gitleaks.toml --redact --log-opts="$OPTS"
storybook-test:
runs-on: ubuntu-latest
timeout-minutes: 20 # longest green run ~14 min
# Component-test coverage: renders every story in headless Chromium and
# runs its play function plus axe — the hard gate for BQR-5.4 a11y.
#
# This used to run @storybook/test-runner against a live Storybook dev
# server. That runner is a Storybook 8/9 tool: under Storybook 10 its jest
# runtime cannot load the config at all (`throwHooksUnsupported`), so all
# 93 suites failed at setup and 0 tests ran. The vitest project is the
# supported path and needs no dev server.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
# version intentionally unset: package.json packageManager (pnpm@10.6.5) is the single source
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
# Chromium was downloaded from scratch in every job that needs it. Key the
# cache on the resolved @playwright/test version so a bump invalidates it.
- name: Resolve Playwright version
id: pw
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: pw-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw.outputs.version }}-chromium
- name: Install Playwright browser
if: steps.pw-cache.outputs.cache-hit != 'true'
timeout-minutes: 10
run: pnpm exec playwright install chromium
# Bounded, cached and self-naming: see the e2e job's steps of the same names.
- name: Week of the Ubuntu package cache
id: apt-week
run: echo "week=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
- name: Restore Ubuntu packages for Playwright
id: apt-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/playwright-apt
key: playwright-apt-${{ runner.os }}-${{ steps.pw.outputs.version }}-chromium_${{ steps.apt-week.outputs.week }}
restore-keys: playwright-apt-${{ runner.os }}-${{ steps.pw.outputs.version }}-chromium_
- name: Install Playwright system packages
timeout-minutes: 10
run: PLAYWRIGHT_DEPS_CACHE_DIR="$HOME/.cache/playwright-apt" sh scripts/ci/install-playwright-deps.sh chromium
# Saved straight after the install, so a later test failure still keeps
# this week's packages. Once main has saved a week's, pull requests hit
# it exactly and save nothing; before that, a rerun of a failed shard
# finds what a sibling shard downloaded.
- name: Save Ubuntu packages for Playwright
if: steps.apt-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/playwright-apt
key: ${{ steps.apt-cache.outputs.cache-primary-key }}
# The stall guard fails the run about a minute after a story page goes
# quiet, and names the story (scripts/ci/storybook-stall-guard.ts). A
# frozen page runs no JavaScript, so Vitest's own timeouts cannot fire
# there, and without the guard the job waited for timeout-minutes with a
# log that named no story. Naming any reporter drops Vitest's defaults,
# so the two it uses on GitHub Actions are named as well.
- name: Run storybook tests
run: >-
pnpm test:storybook
--reporter=default
--reporter=github-actions
--reporter=./scripts/ci/storybook-stall-guard.ts
# Sharded because e2e WAS the entire CI critical path: 9m05s of a 9m05s wall,
# 283s of it the `critical` project at one worker and 148s booting the Compose
# stack. Measured per file from the uploaded Playwright JSON, not estimated.
#
# WHY SHARDS AND NOT MORE WORKERS. The `critical` project pins `workers: 1`
# because its journeys toggle a shared kill switch and restore it, so a second
# browser against the same stack observes the bounded window and fails
# (playwright.config.ts). Every shard below boots its OWN Compose stack and
# seeds it from empty, so that constraint holds inside each shard while the
# shards run concurrently. Raising `workers` would have broken it; sharding
# does not, and that was checked first rather than last.
#
# PROVEN BEFORE LANDING. Each shard was run locally against a freshly reseeded
# stack and passed alone: 23 / 21 / 12 / 17 tests. Shards are file-atomic by
# construction — see the `fullyParallel: false` note in playwright.config.ts.
#
# COST. Four Compose stacks instead of one: roughly 4x the runner minutes for
# this job, bought to remove ~4 minutes of wall from every push. Stack boot
# (148s) is now the floor and the next thing worth attacking.
e2e-shard:
name: e2e shard (${{ matrix.shard }}/4)
runs-on: ubuntu-latest
# BQR-5.1: critical Playwright project is a hard gate (auth + properties shell).
# BQC-6.7: the full suite is a hard gate too — deterministic and green
# (fake mail outbox, hydration-safe specs, F-PEOPLE fix); required for
# release-candidate promotion.
timeout-minutes: 25
strategy:
# Every shard reports its own failures; one red shard must not hide the
# others, exactly as test-unit does.
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
env:
CI: '1'
E2E_BASE_URL: http://127.0.0.1:3000
E2E_LOCKED_BASE_URL: http://127.0.0.1:3001
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
# version intentionally unset: package.json packageManager (pnpm@10.6.5) is the single source
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
# The release browser matrix is cached on the resolved Playwright version;
# a dependency bump therefore replaces Chromium, Firefox, and WebKit as
# one coherent test runtime.
- name: Resolve Playwright version
id: pw
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: pw-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw.outputs.version }}-chromium-firefox-webkit
# The cache key names the BROWSER SET, not just the Playwright version.
# Three jobs share ~/.cache/ms-playwright: two need chromium alone and
# this one also needs firefox and webkit for the compatibility gate. On
# a shared key whichever job populated the cache first won, so a hit
# here ran `install-deps` against a chromium-only directory and every
# Firefox and WebKit test failed with "Executable doesn't exist".
- name: Install Playwright release browsers
if: steps.pw-cache.outputs.cache-hit != 'true'
timeout-minutes: 10
run: pnpm exec playwright install chromium firefox webkit
# The browsers' system packages (~119 MB of .deb files) came from
# Ubuntu's mirrors on every run. On 2026-10-22 the mirror GitHub's
# runners prefer trickled, then archive.ubuntu.com did too, and this
# install (~30 s) took 17-25 min: 12 shards hit timeout-minutes, some
# inside a later step that was not at fault. The packages are now cached
# per ISO week, so a warm run downloads only what Ubuntu updated since
# (apt checks every cached file against the signed package lists). The
# script gives any download a deadline, moves to the next mirror once,
# and names the phase and mirror when it gives up
# (scripts/ci/install-playwright-deps.sh). timeout-minutes here only
# backstops the script itself.
- name: Week of the Ubuntu package cache
id: apt-week
run: echo "week=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
- name: Restore Ubuntu packages for Playwright