-
Notifications
You must be signed in to change notification settings - Fork 0
649 lines (629 loc) · 31.7 KB
/
Copy pathci.yml
File metadata and controls
649 lines (629 loc) · 31.7 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
name: CI
# PR/push gates for the Sofra marketing site + control plane. Modeled on
# restaurant-app-frontend's ci.yml (same security posture: no untrusted-input
# expressions in run: steps, third-party actions pinned to immutable SHAs —
# except the non-blocking semgrep job's floating container tag, standard for
# semgrep ci — read-only GITHUB_TOKEN, concurrency cancellation, per-job
# timeouts), adapted to this stack:
# * typecheck + eslint + next build (build-image.yml only runs post-merge,
# so `next build` here is the only pre-merge build gate)
# * prisma_drift: committed migrations must apply cleanly to an empty
# Postgres AND produce exactly the schema.prisma datamodel — catches
# hand-edited schemas without a migration (Prisma 7: no shadow-db flag;
# diff runs against the migrated service container)
# * secret/dep/fs scans: gitleaks, TruffleHog (PR diff), Trivy fs,
# npm audit, OSV-Scanner, semgrep (non-blocking)
# Every install runs `npm ci --ignore-scripts` (Sonar S6505): a dependency's
# postinstall must not execute in a runner that holds GITHUB_TOKEN. Nothing in
# this tree needs one ON A GLIBC RUNNER: @prisma/engines ships the linux-x64
# engine as a file, so `prisma generate` and `prisma migrate deploy` both work
# without its postinstall (measured in a node:22-bookworm container). The Alpine
# image is the exception and rebuilds that ONE package by name — see Dockerfile.
# Local binaries are invoked as node_modules/.bin/<tool>, never `npx`, which
# would fetch-and-run an unpinned package on a miss.
# Deliberately NOT ported from the frontend: njsscan (Express-centric rules;
# semgrep covers TS/Next SAST here) and the license-compliance job (no
# LICENSES.allowlist policy in this repo yet — adopt the frontend's
# allowlist + scoped-exceptions model in a follow-up if wanted; the tree
# currently carries one LGPL-3.0-or-later transitive dep that needs a
# policy decision first). SonarCloud runs via automatic analysis (no CI job —
# do not add one without disabling autoscan).
on:
push:
# main only (was [main, develop]). A merge into `develop` used to fire a push
# run that is bit-identical to the pull_request run of the release PR opened on
# the SAME sha — measured 2026-08-16: runs 31902462483 (push) and 31902465447
# (pull_request), same head_sha, created 4s apart, 255s + 271s for one tree.
# Nothing consumes the CI push run on develop: build-image.yml triggers on its
# own `push: [main, develop]` (not workflow_run), and no workflow in this repo
# uses workflow_run at all. The gate that matters before main is the release
# PR's own run (workspace CLAUDE.md rule 2c).
#
# Side effect that had to be paid for separately: Actions caches are scoped
# per ref and a PR reads only its own scope + its BASE branch's. With no run
# on develop, nothing writes refs/heads/develop, so every fresh PR started
# cold. cache-warm.yml now runs on `push: develop` and does NOTHING but
# populate the three cache keys below — do not re-add a full CI push run.
branches: [main]
pull_request:
branches: [main, develop]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
install_deps:
name: install (cache npm modules)
runs-on: ubuntu-latest
# Least privilege is declared PER JOB, not once at workflow level: a
# workflow-level default is inherited by every job added later, including
# one that should have had none (see e2e_smoke_result's `{}`).
permissions:
contents: read
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: npm
- run: npm ci --ignore-scripts --prefer-offline --no-audit
typecheck:
name: tsc --noEmit
needs: install_deps
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: npm
- run: npm ci --ignore-scripts --prefer-offline --no-audit
# tsc needs the generated Prisma client (lib/generated/prisma).
- run: npm run db:generate
- run: npm run typecheck
eslint:
name: eslint (--max-warnings=0)
needs: install_deps
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: npm
- run: npm ci --ignore-scripts --prefer-offline --no-audit
- run: npm run lint
next_build:
name: next build
needs: install_deps
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 15
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: npm
- run: npm ci --ignore-scripts --prefer-offline --no-audit
# Next.js incremental build cache. Keyed on the lockfile AND a hash of
# every source file, so a hit only ever happens for a tree that would
# produce the same output; restore-keys then falls back to the newest
# cache for the same dependency set, which is what actually pays (Next
# reuses the unchanged compilation units). Scoped `-site-` because the
# e2e job builds the SAME tree with a different NEXT_PUBLIC_SITE_URL and
# must not read this one's output. Measured 2026-08-16: build step 74/79/80s
# cold vs 40s warm on this job.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .next/cache
key: ${{ runner.os }}-next-site-${{ hashFiles('package-lock.json') }}-${{ hashFiles('app/**', 'components/**', 'lib/**', 'messages/**', 'next.config.ts', 'tsconfig.json') }}
restore-keys: |
${{ runner.os }}-next-site-${{ hashFiles('package-lock.json') }}-
# `npm run build` = prisma generate && next build. No DATABASE_URL on
# purpose: nothing may query the DB at build time (control-plane pages
# are dynamic; the Docker image builds the same way).
#
# DO NOT try to share this build with e2e_smoke (or with build-image.yml)
# via an artifact — measured and rejected 2026-08-17, twice over:
# 1. CORRECTNESS. `NEXT_PUBLIC_SITE_URL` is inlined at COMPILE time and
# feeds lib/seo.ts SITE_URL + IS_CANONICAL_SITE, which decide the
# canonical/hreflang tags, every JSON-LD `@id`, sitemap.xml and
# robots.txt. This job bakes https://sofrapiwas.com, e2e_smoke bakes
# http://localhost:3000, build-image bakes a THIRD value (prod or
# staging.sofrapiwas.com). The outputs are not interchangeable: one
# artifact means either the e2e suite drives a prod-canonical build
# that serves a crawlable robots.txt, or this pre-merge build gate
# stops validating the tree that actually ships. That is why the two
# .next/cache entries are also under separate key prefixes.
# 2. NO WALL-CLOCK WIN even if it were safe. This whole job is 62s and
# runs fully PARALLEL to e2e_smoke (205s), so it is not on the
# critical path — deleting it outright would save 0s. Sharing would
# SERIALIZE e2e behind it plus a ~115MB .next upload/download, i.e.
# strictly slower. (Run 31978057222: next build 62s total / 41s step;
# playwright 205s total / 60s build step.)
# build-image.yml is a third, separate case: it triggers on `push` only,
# never on pull_request, so nothing is built three times per PR — and its
# build happens inside the Dockerfile on purpose, so the shipped image is
# built from source rather than from a host-produced tarball.
- run: npm run build
env:
NEXT_PUBLIC_SITE_URL: https://sofrapiwas.com
prisma_drift:
name: prisma (migrations apply + schema drift)
needs: install_deps
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 10
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
# Ephemeral CI-only database — passwordless trust auth instead of a
# hardcoded throwaway password (keeps secret scanners honest).
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: sofra_ci
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgresql://postgres@localhost:5432/sofra_ci
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: npm
- run: npm ci --ignore-scripts --prefer-offline --no-audit
- name: prisma migrate deploy (committed migrations apply cleanly)
run: node_modules/.bin/prisma migrate deploy
- name: prisma migrate diff (migrated DB == schema.prisma)
# --exit-code: empty diff = 0, drift = 2. A failure here means
# schema.prisma changed without `prisma migrate dev` generating the
# matching migration.
run: node_modules/.bin/prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code
npm_audit:
name: npm audit (high+)
needs: install_deps
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 5
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: npm
- run: npm ci --ignore-scripts --prefer-offline --no-audit
- run: npm audit --audit-level=high
gitleaks:
name: gitleaks (secret scan)
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
# Use the binary directly — gitleaks-action requires a paid license
# for organizations; the OSS CLI is free under MIT.
- name: Install gitleaks
# #177: this curl once 504'd and failed this REQUIRED check in 3s on a
# PR whose diff could not contain a secret (run 32473558686); only a
# human re-run recovered it. `--retry` alone is not enough — under `-f`
# a clean HTTP 504 exits 22 without being retried, so
# `--retry-all-errors` is what actually covers transient 5xx.
run: |
GITLEAKS_VERSION=8.21.2
curl -sSfL --retry 3 --retry-delay 2 --retry-all-errors --proto '=https' --proto-redir '=https' --tlsv1.2 "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xzf - -C /tmp gitleaks
sudo mv /tmp/gitleaks /usr/local/bin/gitleaks
- name: Run gitleaks
run: gitleaks detect --verbose --redact --source .
trufflehog:
name: TruffleHog (secret scan, complementary to gitleaks)
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 10
# Only run on PRs — TruffleHog's diff-scan needs BASE != HEAD.
# Gitleaks already does the full-filesystem secret scan on every push.
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: trufflesecurity/trufflehog@cb6aeefd6e2498240d0418e63f69684d28337e7b # v3.91.0
with:
path: ./
base: ${{ github.event.pull_request.base.sha }}
head: ${{ github.event.pull_request.head.sha }}
extra_args: --only-verified
trivy_fs:
name: Trivy filesystem (misconfig + secrets)
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: fs
scan-ref: .
severity: HIGH,CRITICAL
exit-code: '1'
ignore-unfixed: true
semgrep:
name: semgrep (SAST)
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 10
container:
image: semgrep/semgrep
# Non-blocking: surfaces findings without failing the pipeline.
continue-on-error: true
env:
SEMGREP_RULES: p/typescript
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- run: semgrep ci
osv_scanner:
name: OSV-Scanner (dependency CVEs)
needs: install_deps
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: google/osv-scanner-action/osv-scanner-action@6fc714450122bda9d00e4ad5d639ad6a39eedb1f # v2.0.1
with:
scan-args: |-
--lockfile=package-lock.json
--format=table
unit_tests:
name: vitest (unit)
needs: install_deps
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: npm
- run: npm ci --ignore-scripts --prefer-offline --no-audit
# Some tested lib modules import types from the generated Prisma client.
- run: npm run db:generate
# --coverage enforces the vitest.config.ts coverage floor (DEV-PHASES W2
# D9): the pure lib/ modules must stay ≥95% lines/statements/functions,
# ≥90% branches, or the job fails. Raise the floor as coverage grows.
- run: npm run test:coverage
i18n_parity:
name: i18n parity (6 locales)
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 5
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
# Pure Node, no dependencies — replaces the old manual 6-locale checklist.
- run: node scripts/check-message-parity.mjs
file_length:
name: file-length (CLAUDE.md §4)
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 5
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
- run: node scripts/check-single-file.mjs --all
e2e_smoke:
# SHARDED 2 ways (2026-08-17). The whole suite still runs on every PR —
# `--shard=i/2` partitions it, it does not select. Measured on run
# 31991995830: this job was 213s = a 128s FIXED FLOOR (containers 19s,
# checkout+setup-node 7s, npm ci 16s, cache restores 11s, generate+migrate+
# seed 4s, next build 61s, post-job cache save 4s, job setup/teardown ~6s)
# + 85s of tests (79s of specs + ~6s of Playwright start, standalone-server
# boot and teardown). Sharding buys wall clock only where the removed test
# time exceeds the floor each new job re-pays; here the shards run on
# SEPARATE runners in parallel, so the floor is paid concurrently, not
# serially, and the arithmetic is 128 + ~42 + an aggregate job, not
# 2 x 213. Playwright splits at file granularity for these specs (verified
# with `playwright test --shard=i/2 --list`). Re-listed 2026-08-22, when the
# contact-intake spec joined (and the invite-resend / partner-tenant-dns /
# backup-agent-box-binding specs that had landed since the previous listing
# were found missing from it): shard 1 = admin-backups + admin-tenants +
# backup-agent-box-binding + backup-alert-cron + billing-mollie (skips
# without a Mollie key) + contact-intake + control-auth + health +
# invite-resend + login-timing + owner-dashboard + owner-payments-pending +
# partner-base-domain = 41 tests, shard 2 = partner-client-domain +
# partner-tenant-dns + partner-tenant-panel + partner-trial +
# self-serve-signup + trial-warning-cron = 34 tests. The seconds above are
# the 2026-08-17 run's, not this split's — the balance is what was re-checked.
# The count imbalance is smaller than it reads: shard 2 carries
# self-serve-signup, which is 15 of its 34 and the slowest file in the suite.
# Do NOT raise the shard count without re-measuring: at 3 shards the
# residual per-shard test time (~27s) is a fifth of the floor, so the win
# collapses while the compute cost keeps rising.
#
# Isolation (the reason this is safe with `workers: 1` in
# playwright.config.ts): each shard is its own runner, so it gets its OWN
# postgres service container, its own migrate+seed, its own standalone
# server on :3000 and its own in-memory login rate-limit bucket. Nothing is
# shared between shards, and each shard performs FEWER logins against its
# own 20/IP/15min bucket than the unsharded job did.
#
# login-timing.spec.ts is the one spec that had to be placed deliberately:
# it compares login medians with an absolute 40ms floor, so it must not
# share a CPU with another spec's browser. It does not — a shard is a whole
# 2-core runner and Playwright still runs one worker — and it sits in
# shard 1, whose only neighbours are three cheap logins and three API
# probes. Its assertions are untouched.
#
# The REQUIRED check context stays `playwright (login smoke)` and is now
# produced by the aggregate e2e_smoke_result job below (same pattern as the
# frontend repo's screenshots_result); these per-shard checks are suffixed
# and are not individually required. Verify with
# `gh api repos/piwas-21/sofra/rulesets`.
name: playwright (login smoke) shard ${{ matrix.shard }}/2
needs: install_deps
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 15
strategy:
# Both shards always report. fail-fast would cancel the sibling on a
# failure, and a cancelled shard tells you nothing about whether its half
# of the suite passes — you would re-run the whole PR to find out.
fail-fast: false
matrix:
shard: [1, 2]
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
# Ephemeral CI-only DB — passwordless trust auth, same as prisma_drift.
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: sofra_ci
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgresql://postgres@localhost:5432/sofra_ci
NEXTAUTH_URL: http://localhost:3000
AUTH_TRUST_HOST: "true"
E2E_ADMIN_EMAIL: e2e-admin@example.com
E2E_PARTNER_EMAIL: e2e-partner@example.com
# The suite's own registry, NOT the sibling deploy repo (which CI has no
# checkout of). An absent registry is not a neutral condition here: since O2
# an unreadable one makes the signup fail closed, so without this the
# self-serve specs would assert an account and correctly get a lead.
TENANT_REGISTRY_PATH: ${{ github.workspace }}/tests/e2e/fixtures/registry.yml
# Placeholder. The provisioning specs only observe the O2 payment gate, which
# refuses BEFORE the GitHub round-trip; when the gate passes a request the
# call fails on this value, and that failure is the proof it passed.
PROVISION_GITHUB_TOKEN: ghp_ci_placeholder_never_valid_000000000000
# No Resend key in CI, so sendEmail logs instead of calling out. Stated
# explicitly rather than relied on, since a leaked key here would mail real
# addresses from every signup the suite performs.
RESEND_API_KEY: ""
WAITLIST_TO: e2e-founder@example.com
# DESTRUCTIVE, and off in production by decision (lib/backup-job-policy.ts).
# On here, and only here, because the guards ARE the feature: the last-copy
# refusal and the typed-slug check cannot be proven end to end against a
# surface that does not render. The spec asserts the refusals.
BACKUP_DELETE_ENABLED: "true"
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: npm
- run: npm ci --ignore-scripts --prefer-offline --no-audit
# Browser binaries keyed on the RESOLVED @playwright/test version (read
# from the installed package, i.e. what package-lock.json pinned), not on
# the lockfile hash: the download only changes when Playwright does, and
# a stale-version hit would run a browser the client refuses to drive.
- name: Resolve Playwright version
run: echo "PLAYWRIGHT_VERSION=$(node -p "require('./node_modules/@playwright/test/package.json').version")" >> "$GITHUB_ENV"
# RESTORE-only in both shards, SAVE from shard 1 only. Both shards compute
# the same cache keys, and two concurrent jobs reserving one key means the
# loser logs `Unable to reserve cache with key ...` and uploads nothing —
# so a full actions/cache here would burn a duplicate upload on every run
# to produce a warning. Shard 1 owns the write; the shards build the same
# tree with the same env, so its .next/cache is what shard 2 wants next
# time anyway. cache-warm.yml still writes both keys on push: develop.
- name: Restore Playwright browsers
id: playwright_cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
# Same incremental Next cache as next_build, under its OWN key prefix:
# this job builds with NEXT_PUBLIC_SITE_URL=http://localhost:3000, so its
# output is not interchangeable with the site build's.
- name: Restore .next cache
id: next_cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .next/cache
key: ${{ runner.os }}-next-e2e-${{ hashFiles('package-lock.json') }}-${{ hashFiles('app/**', 'components/**', 'lib/**', 'messages/**', 'next.config.ts', 'tsconfig.json') }}
restore-keys: |
${{ runner.os }}-next-e2e-${{ hashFiles('package-lock.json') }}-
- run: npm run db:generate
# Throwaway secrets generated at runtime (never committed) — the DB is a
# disposable service container, so these only ever protect this one run.
- name: Generate throwaway credentials
run: |
{
echo "AUTH_SECRET=$(openssl rand -hex 24)"
echo "E2E_ADMIN_PASSWORD=$(openssl rand -hex 12)"
echo "E2E_PARTNER_PASSWORD=$(openssl rand -hex 12)"
# The cron bearer (lib/cron-auth.ts). Generated rather than written
# as a literal for the same reason as the rest: a constant here is a
# secret-scanner hit that teaches people to ignore the scanner. The
# trial-warning spec reads it from the environment and hard-ERRORS
# without it — a 401 that skips would verify nothing.
echo "CRON_SECRET=$(openssl rand -hex 16)"
# The backup agent's bearer (ADR-014). Same reasoning as CRON_SECRET:
# the /admin/backups spec reads it and hard-ERRORS without it, since a
# run that 401s on every call would verify nothing while exiting 0.
echo "BACKUP_AGENT_SECRET_E2E_BACKUP_BOX=$(openssl rand -hex 16)"
# A SECOND box's credential (lib/backup-agent-auth.ts). The per-box
# binding spec needs a bearer that is VALID and belongs to another
# box — a wrong token cannot prove that binding, because it is
# refused for the ordinary reason.
echo "BACKUP_AGENT_SECRET_E2E_OTHER_BOX=$(openssl rand -hex 16)"
} >> "$GITHUB_ENV"
- name: Apply migrations
run: node_modules/.bin/prisma migrate deploy
- name: Seed ADMIN + PARTNER
run: node scripts/seed-e2e.mjs
- name: Install Playwright chromium
if: steps.playwright_cache.outputs.cache-hit != 'true'
# Drop the Microsoft apt sources first: `--with-deps` runs `apt-get
# update`, which intermittently fails on packages.microsoft.com serving
# a malformed InRelease ("NOSPLIT") — unrelated to our deps. The
# ubuntu-latest image already carries chromium's runtime libs, so a
# deps failure is non-fatal; install the browser binary regardless.
run: |
sudo rm -f /etc/apt/sources.list.d/microsoft*.list /etc/apt/sources.list.d/*azure*.list 2>/dev/null || true
node_modules/.bin/playwright install --with-deps chromium || node_modules/.bin/playwright install chromium
- name: Verify cached Playwright chromium
if: steps.playwright_cache.outputs.cache-hit == 'true'
# Cheap no-op on a good cache (the binary is already under
# ~/.cache/ms-playwright); re-downloads if the archive was partial, so a
# corrupt cache degrades to the cold path instead of failing the suite.
run: node_modules/.bin/playwright install chromium
- name: Save Playwright browsers (shard 1 owns the write)
if: matrix.shard == 1 && steps.playwright_cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
# Build WITHOUT DATABASE_URL in scope (repo rule: nothing queries the DB
# at build time). Playwright's webServer (`npm run start:standalone`)
# then serves the STANDALONE output — the same server.js the Docker image
# runs — with the DB + auth env from the job.
- name: Build
run: npm run build
env:
DATABASE_URL: ""
NEXT_PUBLIC_SITE_URL: http://localhost:3000
# Saved HERE rather than in a post-job step so a failing spec still leaves
# the next PR a warm build cache (actions/cache's implicit save skips on a
# failed job).
- name: Save .next cache (shard 1 owns the write)
# cache-hit is the PRIMARY key only (a restore-keys fallback leaves it
# false), so this writes exactly when the old implicit save would have:
# whenever the source hash moved. Skipping an existing primary key also
# keeps a re-run of the same sha from logging a reserve conflict.
if: matrix.shard == 1 && steps.next_cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .next/cache
key: ${{ runner.os }}-next-e2e-${{ hashFiles('package-lock.json') }}-${{ hashFiles('app/**', 'components/**', 'lib/**', 'messages/**', 'next.config.ts', 'tsconfig.json') }}
# Same bar as scripts/e2e-suite.sh, which hard-exits on a non-test_ key. The
# spec's own test.skip only skips the BILLING file — it would leave a live_
# key in the environment of the running server, so the refusal belongs here
# too (CLAUDE.md §9).
- name: Refuse a non-test Mollie key
env:
MOLLIE_API_KEY: ${{ secrets.MOLLIE_API_KEY_TEST }}
run: |
case "$MOLLIE_API_KEY" in
""|test_*) ;;
*) echo "MOLLIE_API_KEY_TEST is not a test_ key — refusing" >&2; exit 1 ;;
esac
# Same refusal, one provider over, and the stake is higher: the mint spec
# CREATES Stripe connected accounts. A test-mode account is deletable and the
# spec deletes every one it makes; a live account is a real business's KYC
# record. So a non-sk_test_ value fails the job rather than reaching a runner.
- name: Refuse a non-test Stripe key
env:
STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY_TEST }}
run: |
case "$STRIPE_API_KEY" in
""|sk_test_*) ;;
*) echo "STRIPE_API_KEY_TEST is not an sk_test_ key — refusing" >&2; exit 1 ;;
esac
- name: Run smoke
# Billing E2E runs against the REAL Mollie API when a test_ key is
# available. Optional on purpose: with no secret the billing spec SKIPS
# with a stated reason (never silently passes) and the other 15 tests still
# run. Scoped to this step so the key is not in the environment of
# `npm ci` or the browser install — installs are --ignore-scripts now,
# but a narrow scope for a live API key does not depend on that.
# Fork PRs never receive it: the trigger is `pull_request`, not
# `pull_request_target`.
env:
MOLLIE_API_KEY: ${{ secrets.MOLLIE_API_KEY_TEST }}
# The mint E2E (tests/e2e/connect-mint.spec.ts) runs against the REAL Stripe
# API when an sk_test_ key is available, and deletes every account it creates
# — asserted, not best-effort. Optional the same way the Mollie key is: with
# no secret the spec SKIPS with a stated reason. Until the secret exists, the
# chain is proven by a local run of scripts/e2e-suite.sh and nothing more.
STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY_TEST }}
# Mollie validates webhook reachability at payment creation and 422s a
# localhost URL, so real payments cannot be created from a runner without
# this. The spec POSTs the real payment id to the local handler itself;
# only the delivery hop is stood in for. See lib/billing.ts webhookUrl().
MOLLIE_WEBHOOK_URL: https://example.com/sofra-ci-webhook-sink
# Via env, not interpolated into the command — house rule for anything
# that reaches a `run:` (githubactions injection posture), even a
# matrix literal.
SHARD: ${{ matrix.shard }}
# scripts/e2e-suite.sh is deliberately NOT sharded: locally the whole
# suite runs in one go against one throwaway Postgres, which is the
# cheaper thing there (one build, one container).
run: npm run test:e2e -- --shard="$SHARD/2"
e2e_smoke_result:
# The REQUIRED check. Green iff BOTH shards succeeded — a failed, skipped or
# cancelled shard leaves needs.result != success, so a half-run suite can
# never read as a pass. Keeps the required context name stable while the
# shard jobs carry suffixed names (see the ruleset note on e2e_smoke).
name: playwright (login smoke)
if: always()
needs: e2e_smoke
runs-on: ubuntu-latest
timeout-minutes: 5
# Reads only the matrix result from the `needs` context — no repo access.
permissions: {}
steps:
- name: Gate on matrix result
env:
RESULT: ${{ needs.e2e_smoke.result }}
run: |
echo "playwright shard matrix result: $RESULT"
[ "$RESULT" = "success" ] || { echo "::error::a playwright smoke shard did not pass"; exit 1; }