Repository navigation
1162 lines (1151 loc) · 66.8 KB
/
Copy pathci.yml
File metadata and controls
1162 lines (1151 loc) · 66.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
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:
# The default branch is `master` (see `git remote show origin`). This used
# to say `main`, which meant the workflow never ran on a merge — only on
# pull requests — and nothing ever verified what actually landed.
push: { branches: [master] }
pull_request:
# New 2026-09-13, alongside the `changes` job below: a second push to the
# same pull request should not wait out the first push's run before its own
# starts, and the first run's result stops mattering the moment a newer
# commit lands, so cancel it rather than let it keep spending runner
# minutes. Grouped by PR number where one exists — so two pushes to the same
# pull request cancel each other — and by ref otherwise, so two rapid
# pushes to `master` do the same. `release.yml` also groups by
# `github.ref` but sets `cancel-in-progress: false`, for the opposite
# reason: a half-cancelled `imagetools create` there leaves a tag pointing
# at whichever manifest list won, so that workflow lets every run finish.
# Nothing here writes anything external, so a cancelled run of this
# workflow leaves nothing half-done to clean up.
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
# The floor for every job below, and no job overrides it: nothing in this
# workflow writes anything back to GitHub. It checks out, compiles, runs
# tests, brings a compose stack up and renders a chart. Without this block
# the default token is whatever the repository setting says — historically
# read/write — so a compromised action in any of these six jobs could push
# a commit or a package (CodeQL actions/missing-workflow-permissions).
#
# `actions/upload-artifact@v4` in `e2e` does not need `actions: write`: it
# uploads through the runtime token the runner injects, not `GITHUB_TOKEN`.
# `release.yml` is the only workflow here that writes, and it scopes its
# own permissions per job.
#
# `pull-requests: read` was added 2026-09-13 for the `changes` job below:
# `dorny/paths-filter` needs it to list a pull request's changed files
# through the GitHub REST API — `action.yml` defaults its `token:` input to
# `${{ github.token }}`, so that path is taken whether or not the job checks
# the repository out. Still read-only, so it does not contradict the
# paragraph above — the job only decides which of the jobs below need to run
# at all, it does not write anything back. It is also enough for a pull
# request opened from a FORK: this is `pull_request`, not
# `pull_request_target`, so the token is read-only whatever this block says,
# and `pulls.listFiles` is a read of the BASE repository, which is public.
permissions:
contents: read
pull-requests: read
env:
CARGO_TERM_COLOR: always
# NOT set here: `CYPRESS_INSTALL_BINARY`. It used to be `0` at workflow
# level, which also applied to the `e2e` job below — whose whole point is
# to run Cypress. Once `bf9811d` fixed the `pnpm/action-setup` failure
# that had stopped every earlier run before this point, that single line
# is what failed the two runs after it ("Command 'cypress' not found").
# The jobs that do not need the binary opt out individually instead.
jobs:
# Added 2026-09-13. Decides which of the jobs below actually need to run,
# so a docs-only or chart-only change does not pay for a Rust compile and
# two Docker image builds it cannot possibly have broken. NOT "does not pay
# for a pnpm install and a browser download": `just fmt-check-web` runs
# prettier over the whole repository, so `web` runs on a docs-only change
# too, deliberately — the `web` filter below says why at length.
# `dorny/paths-filter`, pinned to a full commit SHA rather than the `@v4`
# tag, matching this file's convention for third-party actions elsewhere
# (`taiki-e/install-action`, `azure/setup-helm`): verified 2026-09-13 that
# `git/ref/tags/v4.0.3` resolves to `ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d`
# with `"type": "commit"` — a lightweight tag pointing straight at it, the
# same kind of check `self-checks` records for its own SHA pins below.
changes:
runs-on: ubuntu-latest
outputs:
rust: ${{ steps.filter.outputs.rust }}
deny: ${{ steps.filter.outputs.deny }}
web: ${{ steps.filter.outputs.web }}
e2e: ${{ steps.filter.outputs.e2e }}
deploy: ${{ steps.filter.outputs.deploy }}
steps:
# `fetch-depth: 0` (full history) rather than the default shallow
# clone: on a `push` to `master` the action runs
# `git diff --no-renames --name-status <event.before>..master`, and a
# shallow clone may not have `before` at all. On `pull_request` events
# it never looks at the checkout — `action.yml` defaults `token:` to
# `${{ github.token }}`, and with a token present the pull-request
# branch of `getChangedFiles` calls `pulls.listFiles` instead. So this
# step is load-bearing on push and inert on a pull request.
#
# `--no-renames` is why a file moved OUT of a filtered directory still
# fires that filter: git reports the move as a delete plus an add, and
# the REST path splits `renamed` the same way. None of the rules below
# carries an `added|modified:` status key, so deletions match too.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0
persist-credentials: false
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: filter
with:
# A `- *anchor` inside one of these lists is a NESTED list after
# `js-yaml` parses it, not a splice — YAML aliases do not splat.
# It works anyway, and that is checked rather than assumed:
# `src/filter.ts` at the pinned commit declares
# `FilterItemYaml[] // Supports referencing another rule via YAML
# anchor` and `parseFilterItemYaml` recurses into arrays and
# `flat()`s the result, so `[[…], […], 'x']` compiles to the same
# flat list of picomatch matchers a hand-written list would. The
# default `predicate-quantifier` is `some`, so a file matching any
# one pattern sets the output.
filters: |
rust: &rust
- 'backends/**'
- '.xtask/**'
- 'sdks/rust/**'
- 'examples/merchant-demo/**'
- 'schemas/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'rustfmt.toml'
- 'clippy.toml'
- '.cargo/**'
# `cargo nextest run --workspace` and `just verify-ignored` both
# read this, and nothing else in this workflow does. It is where
# the `postgres-containers` test group's `max-threads = 1` lives
# — the serialisation that keeps the container suites from
# racing each other for a host port — and where the
# `nextest-version` floor is declared. Deleting either is a
# change to what the `rust` job actually runs, invisible to
# every other filter here.
- '.config/nextest.toml'
# The Node half this job genuinely uses, not a spare tyre: the
# webhook signature-parity test drives the SHIPPING Node SDK in
# a subprocess (`VPAY_REQUIRE_NODE: 1` below — it fails rather
# than skips), so this job runs `pnpm --filter
# @vaam-apps/vpay-sdk build`, and `sdks/nodejs/tsconfig.json`
# extends `tsconfig.base.json`.
- 'sdks/nodejs/**'
- 'tsconfig.base.json'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.nvmrc'
- '.npmrc'
- 'justfile'
- '.github/workflows/ci.yml'
# The `web` job's SOURCE trees, split out of `web` so that `e2e`
# can take them WITHOUT the repository-wide prettier scope `web`
# also has to carry (see the `web` comment below). `e2e` runs no
# formatter, so a markdown edit must not build two Docker images.
# This key exists to be an anchor; its own output is set by the
# action and deliberately used by no job.
web-sources: &web-sources
- 'frontends/**'
- 'sdks/nodejs/**'
- 'sdks/stripe-compat/**'
- 'sdks/stripe-js/**'
- 'examples/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'tsconfig.base.json'
- '.prettierrc.json'
- '.prettierignore'
- '.nvmrc'
- '.npmrc'
- 'justfile'
- '.github/workflows/ci.yml'
# `web-sources` PLUS every file prettier can parse, anywhere in
# the repository, because the `web` job runs `just fmt-check-web`
# and that recipe is `pnpm exec prettier --check .` over the WHOLE
# tree — `docs/**`, the root `*.md`, `config/**`, `compose*.yml`,
# the chart's `values.yaml`, the other two workflow files. A
# `web` filter scoped to the front-end trees would have let a
# docs-only commit skip the only job that formats docs, and this
# repository requires a status-page edit in the same commit as
# every change: the gate would have been quietly absent on the
# commits most likely to trip it.
#
# By extension rather than by directory on purpose — the question
# is "can prettier parse this file", not "where does it live". The
# list below is prettier 3's own parseable set for the extensions
# that exist here plus the ones it would pick up if they appeared;
# a prettier upgrade that adds a parser (SQL, TOML) means adding
# the extension here in the same commit.
#
# The honest cost: `web` now runs on nearly every commit, which is
# the truth about `fmt-check-web` rather than a regression in this
# filter. What still skips it is the case that matters most for
# runner minutes — a Rust-only change (`*.rs`, `*.sql`, `*.toml`
# are not prettier's). Splitting `fmt-check-web` into its own
# always-run job would restore the rest, at the cost of a second
# `pnpm install` and a new check name; that is a topology decision
# for the maintainer, not one to take inside a filter list.
web:
- *web-sources
# Prettier 3 reads `.gitignore` as its own ignore source (see
# `.prettierignore`'s header), so editing it changes which files
# `fmt-check-web` checks.
- '.gitignore'
- '**/*.md'
- '**/*.markdown'
- '**/*.mdx'
- '**/*.json'
- '**/*.json5'
- '**/*.jsonc'
- '**/*.yml'
- '**/*.yaml'
- '**/*.css'
- '**/*.scss'
- '**/*.less'
- '**/*.html'
- '**/*.vue'
- '**/*.graphql'
- '**/*.js'
- '**/*.jsx'
- '**/*.mjs'
- '**/*.cjs'
- '**/*.ts'
- '**/*.tsx'
- '**/*.mts'
- '**/*.cts'
deny:
# `**/Cargo.toml` matches the root manifest too — a leading
# `**/` matches zero directories. Checked rather than assumed,
# against picomatch 2.3.1 (what this action's `package.json`
# depends on) with the `{dot: true}` options `filter.ts` passes.
- '**/Cargo.toml'
- 'Cargo.lock'
- 'deny.toml'
- '.github/workflows/ci.yml'
# The union of `rust` and `web-sources` via the anchors above,
# plus the paths that only the compose stack itself touches — the
# Dockerfiles and the compose files that assemble them, `config/`
# (mounted into the containers), and `.env.example`. Hand-listing
# them a second time here would be the exact drift this file
# otherwise avoids by naming a `just` recipe instead of copying
# its steps.
e2e:
- *rust
- *web-sources
- 'compose*.yml'
- 'config/**'
- '.env.example'
- 'backends/Dockerfile'
- 'frontends/Dockerfile'
# `examples/shop/Dockerfile` is the third image this stack
# builds; `examples/**` in `web-sources` already covers it.
- '.dockerignore'
# compose.e2e.yml mounts `deploy/dev/postgres-init/*.sql` into
# the Postgres container's `docker-entrypoint-initdb.d`, so it
# is what creates the shop's database. The only `deploy/` path
# this job reads, and it is not under `deploy/helm/`.
- 'deploy/dev/**'
deploy:
# `just helm-check` reads `deploy/helm/vpay` and nothing else in
# the tree (`chart := "deploy/helm/vpay"` in the justfile); the
# kubeconform schemas it also needs come over the network.
- 'deploy/helm/**'
- 'justfile'
- '.github/workflows/ci.yml'
# The promises this repo makes about itself. Fast, and first — if these
# fail, nothing else is worth running.
#
# Deliberately exempt from the path filtering above: it has no `needs:`
# and no `if:`, so it always runs. Three reasons. It is fast — no Docker,
# no browser, no big test binary, a handful of `cargo xtask` and `just`
# invocations — so the cost of running it unconditionally is low. Its
# dozen-plus sub-checks are cross-cutting — docs, SDKs, schema, migrations,
# serde, repositories, links, toolchain, UI conventions — in a way that
# resists being scoped to any single path list without real risk of
# silently excluding the one path that would have caught a regression;
# that is exactly the failure mode this file's filters exist to avoid
# introducing. And it is the ONLY job here that reads `docs/**`, so
# `verify-status`, `verify-links` and `verify-docs` would otherwise be
# skipped on precisely the changes they exist to check.
#
# What is NOT a reason, and was written here before it was checked: that
# this job's `name:` is pinned as a required status check. It is not.
# `master` carries no branch protection and no ruleset on 2026-09-13
# (`gh api repos/vaam-apps/vpay/branches/master/protection` → 404 "Branch
# not protected"; `gh api repos/vaam-apps/vpay/rules/branches/master` →
# `[]`), which is also what makes the filters above worth being careful
# with: nothing here blocks a merge, so a job that silently does not run
# is a green tick with less behind it, and there is no required-check
# machinery to notice. The `name:` still stays exactly as it was, for the
# day someone does pin it. (The same claim appears on the job below and
# predates this change; it is left there rather than rewritten in passing.)
verify:
# The job's display name is what branch protection pins as a required
# check, so it stays exactly as it was; the new check is named in its
# own step instead.
name: self-checks (no-mocks, status)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
# One source of truth for the compiler version: `rust-toolchain.toml`.
# `dtolnay/rust-toolchain@stable` would install *today's* stable and let
# rustup silently download the pinned one on the first `cargo` call —
# or, on a rustup with auto-install disabled, fail. Reading the pin
# here keeps CI, developers and `backends/Dockerfile` on one number.
- id: toolchain
run: echo "channel=$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml)" >> "$GITHUB_OUTPUT"
# Pinned to a commit of `master` (the ref the `toolchain:` input is
# documented for) rather than the mutable branch, for the same reason
# `cargo deny` runs at all. Bump deliberately.
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master, 2026-08-05
with:
toolchain: ${{ steps.toolchain.outputs.channel }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- run: cargo xtask verify-no-mocks
- run: cargo xtask verify-status
- name: verify-errors (ADR-0011)
run: cargo xtask verify-errors
# ADR-0015 decision (3): the parity matrix is machine-checked and
# "fails the build the moment the document and the trees disagree",
# and `docs/plans/2026-09-03-step8-production-gate.md` §3 spelled that
# out as "in `just verify` (and therefore CI's `self-checks`)". Until
# 2026-09-04 the parenthesis was false — the gate ran in the justfile
# and nowhere in this file — so a ✅ in `docs/sdks/parity.md` naming a
# test that no longer exists would have merged with CI green.
#
# Before `verify-docs`, because `verify-docs` is a report rather than a
# gate and the justfile promises it runs last.
#
# The job's `name:` above is deliberately unchanged — it is a required
# status check, and renaming it would silently stop gating.
- name: verify-sdk-parity (ADR-0015)
run: cargo xtask verify-sdk-parity
# New 2026-09-05, and it is here in the same commit as the justfile
# change for the reason the paragraph above records: a comment in the
# justfile saying "CI's self-checks runs exactly this list" is only
# kept honest by someone reading this file beside it.
#
# `actions/checkout` leaves a real index, which is what
# `verify-links` reads: it asks `git ls-files` rather than walking
# directories, so a link cannot be satisfied by a file that is not in
# the repository. It needs no network — `verify-citations`, which does,
# is deliberately not run here.
- name: verify-links
run: cargo xtask verify-links
# The sixth gate, and the one this job would have been the last place
# to catch: on 2026-09-05, deleting `publishConfig.access` from
# `sdks/nodejs/package.json` passed the lockfile, every typecheck,
# every web lint and test, and all five gates above it.
- name: verify-npm-scope
run: cargo xtask verify-npm-scope
# New 2026-09-05, and the seventh gate `just verify` runs. Four steps
# rather than one because each is a different failure to report: `just`
# missing, the version pin unreadable, the CrateStack CLI missing, the
# schema not parsing.
#
# `just` is pinned to a full commit SHA, matching what `web` and
# `deploy` do — this is the step that decides whether a tool that gates
# a merge can be swapped by whoever can move a tag. `rust` and `e2e`
# pin their own `taiki-e/install-action` step too now that every action
# in this workflow is pinned, just at a separately-verified patch
# release (v2.87.16) rather than this v2.87.4.
- uses: taiki-e/install-action@e67fa11c4b9316fa714ddf0abed07a0c3143b95b # v2.87.4
with:
tool: just
# ONE source of truth for the CrateStack version, the same trick the
# `toolchain` step above uses for the compiler channel: the number lives
# in `justfile` as `cratestack_version` and this step reads it back
# rather than repeating it. A pin written in two files is a pin that
# drifts, and the drift is silent — CI would keep passing against a
# release the local gate no longer uses.
#
# The assignment and the emptiness check are not ceremony. Written as
# `echo "version=$(just --evaluate cratestack_version)"`, a failing
# `just` does not fail the step: the substitution's exit status never
# reaches `bash -e`, `echo` succeeds, and `version=` lands in
# $GITHUB_OUTPUT empty — whereupon the install action below treats an
# empty `version` exactly like `latest` and installs whatever the newest
# release happens to be. That is the SHA-pinned, version-pinned step
# quietly becoming a floating one, on a green run. Measured by renaming
# the justfile variable: `just` errored, the step exited 0, and the
# output was `version=`.
- name: resolve the pinned CrateStack version from the justfile
id: cratestack
run: |
version="$(just --evaluate cratestack_version)"
if [ -z "$version" ]; then
echo "::error::could not read 'cratestack_version' out of the justfile; refusing to fall back to the latest release" >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
# The upstream install action, pinned to the commit `v0.12.0` was
# tagged at (`0823bab`) rather than the `@main` its documentation shows
# — https://cratestack.dev/tooling/cli-install. It downloads the
# prebuilt `x86_64-unknown-linux-gnu` binary from that repository's
# releases and verifies it against the published `.sha256` sidecar
# before putting it on PATH, which is why this job does not do the
# `curl | sha256sum -c` dance the `deploy` job does for kubeconform.
#
# `6b3053f` (v0.11.1) until 2026-09-07. Two things were checked when
# the pin moved, because a SHA pin's whole value is that neither can
# be assumed. `git/ref/tags/v0.12.0` resolves to `0823bab382…` and the
# ref is `"type": "commit"` — a lightweight tag, so there is no tag
# object to dereference and the commit above is the tag itself. And
# `action.yml` at the two commits is byte-identical (md5
# 40c0fb26361a4b742f824ac6d4e49a95), so this line moves the provenance
# of the code CI runs and nothing about what it does.
#
# There is NO checksum input to move with it, and that is worth saying
# because the natural assumption is that a pinned action pins a
# digest: the action takes `version` and `github-token` only, and
# fetches `<asset>.sha256` from the same release at run time. What
# pins the binary is therefore the release, not this workflow — the
# v0.12.0 release carries
# `cratestack-cli-x86_64-unknown-linux-gnu-v0.12.0.tar.gz` and its
# sidecar, and reproducing the action's own steps by hand on
# 2026-09-07 gave a matching digest
# (d38eb010b0f61ef3e6d8f9e5f45c35d73abbc904e8249ae32c3bcce75b74b307)
# and a binary that reports `cratestack 0.12.0`.
# No Rust toolchain is involved, and since 2026-09-05 that is a choice
# rather than a necessity. `cratestack-cli` declares
# `rust-version = "1.98.0"` (still 1.98.0 at 0.12.0, re-read from the
# manifest on 2026-09-07); this job's toolchain is whatever
# `rust-toolchain.toml` pins, which was 1.95.0 until that date, so
# `cargo install` here USED to fail outright with "requires rustc
# 1.98.0 or newer" (verified by running it). The pin is 1.98.0 now, so
# it would succeed — the action stays because downloading a
# checksum-verified prebuilt binary is seconds against compiling the
# CLI and its graph on every `self-checks` run, not because the
# compiler cannot do it.
- uses: cratestack/cratestack/.github/actions/install-cratestack-cli@da143158cd4621a0268ff5ee4ec9a99833d45daf # v0.15.0 tag commit (a lightweight tag, checked with git ls-remote). It contains the CLI-download retry fix, cratestack#982, that the previous post-v0.12.0 ref was chosen for. The installed CLI version is the justfile's cratestack_version (read above), independent of this ref.
with:
version: ${{ steps.cratestack.outputs.version }}
# `just check-schema`, not a copy of the command it runs: the recipe is
# what a contributor runs locally and it carries the reasoning, so the
# gate and the local check cannot drift — the same rule `web` follows
# for `just audit-web` and `deploy` for `just helm-check`.
#
# Before this step, `schemas/vpay.cstack` was verified by a human
# running the CLI by hand and pasting the transcript into
# docs/status.md. Nothing re-ran it, and the file is excluded from the
# build graph, so no compiler would have noticed it going stale.
- name: check-schema (schemas/vpay.cstack)
run: just check-schema
# The eighth and ninth gates, both ADR-0016 (2026-09-05). Through the
# justfile rather than `cargo xtask` directly, the same rule
# `check-schema` above follows: the recipe carries the reasoning a
# contributor reads locally, so the gate and the local check cannot
# drift.
#
# They are in this job rather than `rust` because they are conventions
# rather than behaviour — no database, no network, no Node — and because
# `justfile`'s claim that "CI's self-checks job runs exactly this list"
# is only kept honest by someone reading this file beside it.
- name: verify-serde (ADR-0016 standard 3)
run: just verify-serde
- name: verify-repositories (ADR-0016 standard 5)
run: just verify-repositories
# The tenth gate, new 2026-09-05, and the one this job is the ONLY
# place to catch: `backends/Dockerfile` is compiled by nothing in the
# `rust` job, so a `FROM rust:<version>` left behind when
# `rust-toolchain.toml` moves is invisible to every other job here.
# Measured on the branch that moved the pin 1.95.0 -> 1.98.0: the
# mismatch passed `just verify`, `just fmt-check` and the whole of
# `just ci`.
#
# Through the justfile, like the two ADR-0016 steps above and
# `check-schema` before them, rather than `cargo xtask` like the six
# older steps: the recipe carries the reasoning a contributor reads
# locally — in this case which of the two files is authoritative and
# why the Alpine base is deliberately not checked — and running it here
# is what stops the recipe and the workflow from drifting. The gate
# itself is `verify_toolchain` in `.xtask/src/main.rs` either way.
- name: verify-toolchain (the image pin matches rust-toolchain.toml)
run: just verify-toolchain
# New 2026-09-07 (exp26 UI revamp). Pure `git grep` — no pnpm install,
# no Node — so it needs nothing this job does not already have. See
# docs/plans/2026-09-07-ui-revamp.md §6.3 and §9 for what each of its
# four checks catches and why a grep, not a lint rule, is the tool for
# the daisyUI 4→5 class-rename check specifically: a removed class
# still parses and still renders, so nothing else in `just ci` notices.
- name: verify-ui (colour tokens, daisyUI 4→5 renames, !important, cva)
run: just verify-ui
# The TWELFTH gate, new 2026-09-07 (issue #76) — it says ELEVENTH
# until 2026-09-18, counting the xtask commands and skipping `verify-ui`,
# which landed the same day and is a gate in `just verify` like any
# other. And — like
# `verify-toolchain` above — one this job is the only place to catch:
# nothing in `rust` compiles `backends/migrations/*.sql`, and a migration
# file edited after it shipped passes `cargo nextest run --workspace`
# outright, because every test starts from an EMPTY database and applies
# the current files. The failure it prevents only appears against a
# database that applied the earlier bytes — i.e. in production. That is
# not a hypothesis about this workflow: PR #39 made exactly this edit and
# merged with every job here green, and the breakage surfaced only when a
# stack that predated it was restarted (issue #76).
#
# Through the justfile rather than `cargo xtask`, like the four steps
# above it: the recipe carries the reasoning a contributor reads locally
# — in this case why only `*.sql` is hashed, and what the gate
# deliberately does not stop — and running it here is what stops the
# recipe and this workflow from drifting.
- name: verify-migrations (applied migrations are immutable)
run: just verify-migrations
# Every version release-please owns agrees, and every annotated line it
# must rewrite still carries its comment. Nothing else checks this:
# `release.yml` derives its Docker tag from the git ref and never
# compares it against a manifest, so a half-bumped tree is invisible
# until a release actually breaks. See .xtask/src/main.rs's
# `verify_versions`.
- name: verify-versions (release-please's bump is complete)
run: just verify-versions
# New 2026-09-16 (issue #144). Like `verify-migrations` above, this job
# is the only place the drift it catches is visible to CI: nothing in
# `rust` reads `schemas/privacy-inventory.yaml`, so a privacy-relevant
# column added to a migration with no inventory classification would
# pass `cargo nextest run --workspace` outright. The gate derives the
# authoritative column set by parsing `backends/migrations` itself
# (`schemas/vpay.cstack` models less than the whole database), in both
# directions, so a new column cannot land unclassified and a stale row
# cannot survive the column it named. Through the justfile, like the
# steps above it, so the recipe and this workflow cannot drift.
- name: verify-privacy-inventory (every migrated column is classified)
run: just verify-privacy-inventory
# New 2026-09-20, the fifteenth gate, and the only one here that changes
# a number it is itself responsible for checking: its `verify-gates`
# measurer counts the `verify:` recipe's dependencies, and this gate
# joining that recipe moved the tally from fourteen to fifteen in every
# document that states it. It is the only step in this job that reads
# prose: nothing else in `just ci` opens a `*.md` to ask whether a
# number in it is still true, which is how thirteen drifted counts
# survived to the 2026-09-20 documentation survey. Through the justfile,
# like the steps above it, so the recipe and this workflow cannot drift.
- name: verify-doc-counts (every marked number still measures)
run: just verify-doc-counts
# A report, not a gate — it exits 0 whatever it finds (Step 7,
# decision (4)). It is here so `justfile`'s claim that this job runs
# exactly what `just verify` runs stays true, and so the doc-volume,
# long-function and ```ignore-fence numbers are on the record for every
# pull request rather than only on whichever machine last ran it.
- name: verify-docs (a report; never fails)
run: cargo xtask verify-docs
rust:
name: rust
needs: [changes]
# Skipped when the `changes` job's `rust` filter found nothing this push
# or pull request touched — see the filter group's glob list above.
if: needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
env:
# `backends/tests/integration/tests/webhooks.rs` verifies a
# `Vpay-Signature` this server emitted with the SHIPPING Node SDK, in a
# subprocess. That test fails rather than skips when `node` is missing,
# and this flag only changes the message — a skipped parity test is a
# parity claim nobody checked, which is how this suite would go green
# while proving nothing (CLAUDE.md's first failure mode). The Node
# toolchain and the SDK build are set up below.
VPAY_REQUIRE_NODE: 1
# The `rust` job runs no Cypress; skip the ~200 MB binary download that
# `pnpm install` would otherwise trigger.
CYPRESS_INSTALL_BINARY: 0
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
- id: toolchain
run: echo "channel=$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml)" >> "$GITHUB_OUTPUT"
# Pinned to a commit of `master` (the ref the `toolchain:` input is
# documented for) rather than the mutable branch, for the same reason
# `cargo deny` runs at all. Bump deliberately.
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master, 2026-08-05
with:
toolchain: ${{ steps.toolchain.outputs.channel }}
# Block style on purpose. The previous flow-style
# `{ components: rustfmt, clippy }` parsed `clippy` as a *second
# key* with no value — GitHub annotated every run with
# "Unexpected input(s) 'clippy'" and clippy only worked because the
# runner image happens to preinstall it.
components: rustfmt, clippy
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3 # v2.87.16
with:
# Comma-separated in one string; a flow-style `{ tool: nextest, just }`
# would repeat the `components:` mistake noted above.
tool: nextest,just
# Node, and the built `@vaam-apps/vpay-sdk`, for the webhook signature-parity
# test. Before nextest on purpose: the test builds `dist/` on demand if
# it is absent, and a build failure should be reported by the step
# whose job it is rather than as a mysterious test failure.
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: { node-version-file: .nvmrc, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @vaam-apps/vpay-sdk build
# The CrateStack CLI, for `postgres_smoke`'s drift test, which shells
# out to `cratestack migrate baseline --strict` and is a red failure
# without the binary, not a skip (added 2026-09-05; the first CI run of
# that test failed here for exactly that reason — run 33980091252).
# Same two steps as `self-checks`: the version comes from the justfile,
# the action is pinned to the commit the v0.15.0 tag points at
# (`da14315`; v0.12.0's `0823bab` until 2026-09-29, v0.11.1's `6b3053f`
# until 2026-09-07) and verifies the checksum. The evidence
# for both halves of that sentence is in `self-checks` above rather
# than repeated here.
- name: resolve the pinned CrateStack version from the justfile
id: cratestack
run: |
version="$(just --evaluate cratestack_version)"
if [ -z "$version" ]; then
echo "::error::could not read 'cratestack_version' out of the justfile; refusing to fall back to the latest release" >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- uses: cratestack/cratestack/.github/actions/install-cratestack-cli@da143158cd4621a0268ff5ee4ec9a99833d45daf # v0.15.0 tag commit (a lightweight tag, checked with git ls-remote). It contains the CLI-download retry fix, cratestack#982, that the previous post-v0.12.0 ref was chosen for. The installed CLI version is the justfile's cratestack_version (read above), independent of this ref.
with:
version: ${{ steps.cratestack.outputs.version }}
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets -- -D warnings
# Runs the container-backed suites too (vpay-db, vpay-tests-integration,
# both binaries' tests/cli.rs) — ubuntu-latest has a working Docker
# daemon and testcontainers fails hard without one, so these cannot
# silently skip. This job is the evidence for those suites.
- run: cargo nextest run --workspace
# nextest runs NO doctests, so until 2026-09-03 (Step 7) the step above
# was the whole of this job's Rust testing and the workspace's doctests
# — one, `vpay_core::money` — had never been compiled by CI at all. An
# example in a doc comment that nothing compiles is a claim about the
# code that nothing checks. `just test-doc`, not a copy of its command,
# so the gate and the local recipe cannot drift.
- run: just test-doc
# A new `#[ignore]` must not quietly shrink coverage, and a whole test
# binary dropping out of the workspace must not read as "fewer tests,
# still green". The recipe pins both numbers; bump them in the same
# commit that legitimately changes them. It counts what nextest lists,
# which does not include the doctests the step above runs.
- run: just verify-ignored
deny:
name: supply chain
needs: [changes]
# Skipped when the `changes` job's `deny` filter found nothing this push
# or pull request touched — see the filter group's glob list above.
if: needs.changes.outputs.deny == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
- id: toolchain
run: echo "channel=$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml)" >> "$GITHUB_OUTPUT"
# Pinned to a commit of `master` (the ref the `toolchain:` input is
# documented for) rather than the mutable branch, for the same reason
# `cargo deny` runs at all. Bump deliberately.
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master, 2026-08-05
with:
toolchain: ${{ steps.toolchain.outputs.channel }}
- uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1
with: { command: check }
web:
name: web
needs: [changes]
# Skipped when the `changes` job's `web` filter found nothing this push
# or pull request touched — see the filter group's glob list above.
if: needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
env:
# This job never runs Cypress; skip the ~200 MB binary download.
CYPRESS_INSTALL_BINARY: 0
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
# No `version:` here on purpose: pnpm/action-setup@v4 refuses to run
# when both it and package.json's `packageManager` name a version
# ("Multiple versions of pnpm specified"). package.json is the single
# source of truth (pnpm@11.18.0). This was the workflow's first-ever
# run and it failed on exactly that, 2026-09-02.
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: { node-version-file: .nvmrc, cache: pnpm }
# For `just audit-web` below. Pinned to a full commit SHA — matching
# what `deploy` does, and for the reason stated there: a mutable tag is
# a promise from whoever can move it, and this is the step that decides
# whether a known-vulnerable dependency can land. `rust` and `e2e` pin
# their own `taiki-e/install-action` step too now that every action in
# this workflow is pinned, just at a separately-verified patch release
# (v2.87.16) rather than this v2.87.4.
- uses: taiki-e/install-action@e67fa11c4b9316fa714ddf0abed07a0c3143b95b # v2.87.4
with:
tool: just
# Playwright's Chromium lives outside node_modules (~/.cache/ms-playwright)
# and setup-node's pnpm-store cache does not cover it — the same reason
# the `e2e` job caches ~/.cache/Cypress. ~115 MB per cold run, for the
# `test-storybook` step at the end of this job.
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- run: pnpm install --frozen-lockfile
# Immediately after install and before anything is built, so a
# known-vulnerable dependency fails this job in seconds rather than
# after a Storybook build. `just audit-web`, not a copy of its
# commands: the recipe is what a contributor runs locally, and the
# justfile carries the reasoning (two runs, `--audit-level=moderate`
# since issue #103, and why dev dependencies are gated too — every JS
# advisory this repo has had was in dev tooling). `pnpm audit` resolves
# from `pnpm-lock.yaml`, which the step above has just proved is
# current, so this gates what would actually be installed rather than
# what happens to be on disk.
#
# Only this job runs it: `e2e` installs from the same lockfile, and
# auditing it twice per pull request buys nothing.
- name: audit-web (pnpm audit; moderate, high and critical fail)
run: just audit-web
# `just fmt-check-web` (prettier --check over the whole tree), not
# `cargo fmt`'s half of `just fmt-check` — this job has no Rust
# toolchain, and the `rust` job runs `cargo fmt --all -- --check`.
# Added 2026-09-10 with the formatting pass: before it, prettier's
# opinion of this repository was enforced only inside
# `examples/shop`'s own `lint` script, so a `just fmt` no-op was true
# on the day it was measured and nothing kept it true. Runs before the
# build because it needs nothing built.
- name: fmt-check-web (prettier --check)
run: just fmt-check-web
# `just lint-web`, not a copy of its commands — the same reason the
# audit step above runs the recipe: the gate and what a contributor
# runs locally cannot drift. The recipe is `build-sdk-node`, then
# `pnpm -r typecheck`, then `pnpm -r lint`.
#
# The build comes first, and not only to catch an emit-time failure in
# the one package whose `build` actually emits JS (`dist/`, consumed by
# merchants and by `just sdk-conformance-node`). It is a *prerequisite*
# of both checks after it: `sdks/stripe-compat` imports
# `@vaam-apps/vpay-sdk/stripe`, whose types resolve through the package's
# `exports` to `dist/stripe-auth.d.ts`, and `dist/` is gitignored. Run
# in the old order this job failed on a clean checkout with `TS2307:
# Cannot find module '@vaam-apps/vpay-sdk/stripe'`; the lint is worse, because an
# unbuilt `dist/` resolves to `any` and the type-aware rules report the
# missing artefact as 233 `no-unsafe-*` findings instead. The `e2e` job
# builds it for the same reason; neither job can borrow the other's
# artefacts.
#
# `pnpm -r lint` is new on 2026-09-05. Before it, that command died on
# the first package declaring a `lint` script for an ESLint nobody had
# installed, and this job never ran a linter at all.
- name: lint-web (pnpm -r typecheck, then pnpm -r lint)
run: just lint-web
- run: pnpm -r test
# Storybook, restored 2026-09-12 in `frontends/apps/checkout` after the
# `@vaam-apps/ui` cutover deleted `@vpay/ui` and its install with it.
- run: just build-storybook
# Every checkout story rendered in a real Chromium with axe over each —
# `just test-storybook`, not a copy of its command, so the gate and what
# a contributor runs locally cannot drift. The recipe installs the
# browser first (chromium only, no `--with-deps`: this image already has
# the shared libraries) and clears vite's dep cache, which has produced
# a false green before.
#
# It is the only step in this repository that can return a
# colour-contrast VERDICT for the payer's screens: the jsdom axe suites
# compute no colour, and issue #73's Cypress attempt only ever got
# `incomplete` out of the real page.
- name: test-storybook (axe over every checkout story, real browser)
run: just test-storybook
# `pnpm --filter @vpay/ui build-storybook` was the last step here until
# the `@vaam-apps/ui` cutover deleted that package on 2026-09-12, taking
# the checkout's 22 stories and the a11y addon with it. The two steps
# above are the restoration: same stories, now hosted by the app that
# owns them, and the a11y addon is a gate rather than a panel.
e2e:
name: e2e (compose)
needs: [changes]
# Skipped when the `changes` job's `e2e` filter found nothing this push
# or pull request touched — see the filter group's glob list above (the
# union of `rust` and `web` plus the compose/Docker/config paths).
if: needs.changes.outputs.e2e == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: { node-version-file: .nvmrc, cache: pnpm }
# The Cypress binary lives outside node_modules (~/.cache/Cypress) and
# is not covered by setup-node's pnpm-store cache.
- uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3 # v2.87.16
with:
tool: just
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/Cypress
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
# A Rust toolchain in the *e2e* job, which never used to need one.
# `just gen-demo-keys` (below) runs `cargo xtask gen-signing-key`,
# because generating the demo merchant's key pair also means extracting
# its public JWK and its RFC 7638 thumbprint — and reproducing a
# thumbprint in shell would be a second implementation of it. `xtask`
# depends on five small crates and none of the workspace, so this is a
# short compile, not a build of the server.
- id: toolchain
run: echo "channel=$(sed -n 's/^channel = \"\(.*\)\"/\1/p' rust-toolchain.toml)" >> "$GITHUB_OUTPUT"
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master, 2026-08-05
with:
toolchain: ${{ steps.toolchain.outputs.channel }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
# With `CYPRESS_INSTALL_BINARY` unset, the `cypress` package's own
# postinstall downloads the binary during `pnpm install`. `verify` then
# proves it is actually runnable before a stack is built for it.
- run: pnpm install --frozen-lockfile
# Kept even though `pnpm install` normally downloads the binary: the
# `web` job shares this runner's pnpm store (same `cache: pnpm` key)
# with `CYPRESS_INSTALL_BINARY: 0`, and pnpm's side-effects cache can
# then let a later install skip cypress's postinstall entirely. This
# step is idempotent — a no-op when the binary is already there.
- run: pnpm --filter @vpay/e2e exec cypress install
# `cypress verify` runs the Electron binary's smoke test with a 30 s
# default budget. On a cold runner Cypress 15's binary has exceeded it
# (master run 33789060261 after PR #23, minutes after the same step passed
# on the PR) while the specs themselves are unaffected, so the budget is
# raised: a genuinely broken binary still fails, just later.
- run: pnpm --filter @vpay/e2e exec cypress verify
env:
CYPRESS_VERIFY_TIMEOUT: "120000"
# The Node SDK's `dist/` — `sdks/stripe-compat` imports
# `@vaam-apps/vpay-sdk/stripe`, which resolves to `dist/stripe-auth.js`. The `web`
# job builds it too; this job cannot borrow that job's artefacts.
- run: pnpm --filter @vaam-apps/vpay-sdk build
# `@vaam-apps/vpay-stripe-js`'s built ESM, vendored into
# `examples/checkout-browser/dist/stripe-js/` — what `checkout.js`
# imports and what `checkout.cy.ts` (below) actually drives in a real
# browser. Both this build and `mint.mjs`'s `@vaam-apps/vpay-sdk` dependency need
# `pnpm install` above; `@vaam-apps/vpay-sdk`'s own `dist/` was just built by the
# step above this one, and pnpm's workspace symlink resolves
# `examples/checkout-browser`'s dependency to it directly.
- run: just build-checkout-browser
# Two throwaway RS256 key pairs, not one. `gen-demo-keys` depends on
# `gen-e2e-signing-key`, so it still produces the merchant OP's own
# signing key (`vpay-server` will not start without it —
# compose.e2e.yml explains the mount); it *also* produces a registered
# merchant's key pair plus the `demo` profile overlay carrying its
# public JWK.
#
# That second pair is why this job now brings up `-f compose.demo.yml`
# as well. `config/application.yml`'s only merchant, `acme-cameroon`,
# has a placeholder modulus that nobody holds the private half of, so
# against the base file alone no client can complete the
# `private_key_jwt` handshake and `sdks/stripe-compat` could not make a
# single authenticated `/v1` call. The overlay replaces the merchant
# registry with one entry whose key this runner generated moments ago.
#
# Everything else the demo overlay changes is inert here: it stops
# publishing Postgres on the host (nothing in this job connects to it
# from outside a container) and it republishes `vpay-server` through
# `${VPAY_DEMO_PORT:-8080}`, which with the variable unset is the same
# `8080:8080` this job already polled.
#
# The one change that is NOT merely a port is the config overlay:
# compose.e2e.yml sets `VPAY_PROFILE: sandbox`, compose.demo.yml
# overrides it to `demo`, and vpay-config loads ONE overlay — the one
# the profile names. So this stack loads `config/application-demo.yml`
# *instead of* `config/application-sandbox.yml`, and with that,
# `application-sandbox.yml` is exercised by nothing in this workflow
# even though `sandbox` is the CLI's default profile.
#
# **That stopped being inert on 2026-09-07 (exp28), and this paragraph
# used to end "the day a spec signs in, this job stops covering the
# profile it thinks it covers".** `dashboard.cy.ts` signs in now, so a
# redirect URI *is* read, and it is the demo overlay's — `just
# gen-demo-keys` writes `http://localhost:3000/dash/v1/callback` into it
# and refuses to keep an overlay that names a different port. The
# sandbox overlay's copy of the same setting is still exercised by
# nothing in this workflow, which is what docs/status.md's "GitHub
# Actions" row records.
#
# THE `3000` IN THAT STRING IS A DEFAULT, NOT A LITERAL, since
# 2026-09-10 (exp35, issue #78): the port is `justfile`'s
# `demo_dashboard_port`, and `compose.demo.yml` /
# `VPAY_DASHBOARD_REDIRECT_URI` read `${VPAY_DEMO_DASHBOARD_PORT:-3000}`.
# This job deliberately sets NEITHER — it is one stack on a fresh
# runner with nothing to collide with, and the reason the variable
# exists is two stacks on one developer's machine. So every `3000`
# below is that default resolving, and the wait step's URL and this
# paragraph are correct precisely as long as this job keeps not setting
# it. If it ever does, both have to move with it.
- name: generate throwaway OAuth keys — the OP's, and a registered merchant's
run: just gen-demo-keys
- name: build both images and bring up the real stack (rails stubbed by WireMock, not in-process)
run: docker compose -f compose.yml -f compose.e2e.yml -f compose.demo.yml up -d --build
# `vpay-server`'s image is `FROM scratch` and carries no HEALTHCHECK
# (see compose.e2e.yml for why), so readiness is observed from the
# runner instead. This is also the first place in this repository's
# history that proves the image boots at all: a 200 here means the
# binary started, found its config, connected to Postgres and ran
# the migrations — /healthz is a real `SELECT 1`, not a static "ok".
- name: wait for vpay-server to answer 200 on /healthz
run: |
for i in $(seq 1 90); do
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/healthz || true)
if [ "$code" = "200" ]; then
echo "vpay-server: /healthz answered 200 after ${i}s"
exit 0
fi
sleep 1
done
echo "::error::vpay-server never answered 200 on /healthz (last status: '$code')"
docker compose -f compose.yml -f compose.e2e.yml -f compose.demo.yml ps
docker compose -f compose.yml -f compose.e2e.yml -f compose.demo.yml logs --no-color vpay-server
exit 1
- name: wait for the dashboard to answer on :3000
# Since exp28 the root redirects to /login (307) — a signed-out
# dashboard has no page at /. Follow the redirect and wait for the
# login page's 200, which is what a person's browser would see.
run: |
for i in $(seq 1 60); do
code=$(curl -sL -o /dev/null -w '%{http_code}' http://localhost:3000/ || true)
if [ "$code" = "200" ]; then
echo "dashboard: / (followed to /login) answered 200 after ${i}s"
exit 0
fi
sleep 1
done
echo "::error::dashboard never answered 200 on / (last status: '$code')"
docker compose -f compose.yml -f compose.e2e.yml -f compose.demo.yml logs --no-color dashboard
exit 1
# The two browser surfaces Step 9 added, waited on for the same reason
# the dashboard is: Cypress's own failure when a server is missing is
# "could not verify this server is running", which names the baseUrl and
# not the service that never came up. Both images DO carry a
# HEALTHCHECK, so `docker compose up --wait` would cover them — this job
# does not use `--wait`, and adding it here would also start waiting on
# containers whose readiness this job has never depended on.
- name: wait for vpay-checkout and vpay-shop
run: |
set -u
for probe in "vpay-checkout http://localhost:3080/healthz" \
"vpay-shop http://localhost:3001/healthz"; do
name=${probe%% *}; url=${probe##* }
ok=""
for i in $(seq 1 90); do
if curl -fsS -o /dev/null "$url"; then
echo "$name: answered $url after ${i}s"
ok=yes
break
fi
sleep 1
done
if [ -z "$ok" ]; then
echo "::error::$name never answered $url"
docker compose -f compose.yml -f compose.e2e.yml -f compose.demo.yml ps
docker compose -f compose.yml -f compose.e2e.yml -f compose.demo.yml logs --no-color "$name"
exit 1
fi
done
# The dashboard's staff member, created against the RUNNING stack.
#
# `vpay-server staff add` is the only way one is created (ADR-0017
# decision 1), and without it `dashboard.cy.ts` has nobody to sign in
# as: `cy.task('staffPassword')` throws, and five of its seven cases
# fail. `just test-e2e` grew this step on 2026-09-07 (exp28); this job
# is a second copy of the same sequence and did not, so the branch that
# wrote the spec was green locally and would have failed here. The
# recipe is called rather than its body copied, so the two cannot drift
# again.
#
# No `VPAY_DEMO_*` is set anywhere in this job, so the recipe's own
# defaults stand and it addresses the same `vpay-demo` project the
# `docker compose` lines above brought up.
- name: create the dashboard's staff member (dashboard.cy.ts signs in as them)
run: just demo-staff
# `just e2e-specs`, NOT a second copy of the environment it sets.
#
# This step used to spell out VPAY_BASE_URL, the four browser URLs, the
# merchant credential `cy.task('mintCheckoutPaymentIntent')` mints with
# and the staff member's address and password path — all of it also
# spelled in the `test-e2e` recipe, "so a change in one place fails
# loudly". It did not fail loudly. On 2026-09-11 exp51 moved the
# browser fixture from `demo-merchant` to `shop-merchant` in the recipe
# (the demo dashboard reads exactly one tenant and it is the shop's);
# this copy kept saying `demo-merchant`, and run 34555068739 failed
# `checkout.cy.ts` 0/1 and `dashboard.cy.ts` 8/11 on a branch whose
# `just test-e2e` was green. The recipe is the single source now, the
# same arrangement `just demo-staff` above, `just lint-web`,
# `just check-schema` and `just helm-check` already use.
#
# No `VPAY_DEMO_*` is set anywhere in this job, so the recipe's own
# defaults stand — 8080, 3000, 3001, 3080, 8082 and
# `.e2e/vpay-demo/staff-password.txt`, which are exactly the ports the
# `docker compose` lines above published and the file `just demo-staff`
# wrote.
#
# TWO Cypress runs, not one (Step 9, lane 6). The recipe runs
# `pnpm --filter @vpay/e2e e2e`, which chains `e2e:default` and
# `e2e:framed`; the second sets `VPAY_E2E_FRAMED=1`, which is what
# `cypress.config.ts` reads to select `shop-embedded.cy.ts` and turn off