-
Notifications
You must be signed in to change notification settings - Fork 1
1567 lines (1515 loc) · 94 KB
/
Copy pathdaily-stable.yml
File metadata and controls
1567 lines (1515 loc) · 94 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
# Daily Stable E2E — daily successor to weekly-stable.yml.
# Same machinery (@stable suite, QA Platform POST, failure issue) but runs every
# day at 05:00 BRT and writes its own history to reports/daily-history.jsonl
# (via the HISTORY_FILE override on the shared scripts/append-weekly-history.mjs).
# weekly-stable.yml is kept in the repo but disabled as a fallback.
name: Daily Stable E2E
run-name: "Daily Stable E2E — ${{ github.event_name == 'schedule' && 'scheduled' || github.actor }}"
on:
schedule:
- cron: "0 8 * * 1-5" # 05:00 BRT (UTC-3), Monday–Friday
workflow_dispatch:
inputs:
langflow_image:
description: "Langflow image repository (e.g. langflowai/langflow-nightly or langflowai/langflow)"
required: false
default: "langflowai/langflow-nightly"
langflow_image_tag:
description: "Image tag (e.g. latest, 1.5.1.dev36, 1.10.1rc3). Use a multi-arch tag — runners are amd64, so do NOT pick an -arm64 variant."
required: false
default: "latest"
shards:
description: "Number of parallel shards for the @stable run (default 4)."
required: false
default: "4"
retries:
description: "Override Playwright retries for this run (e.g. 0 for a fast, unamplified validation signal). Empty = config default (2 in CI)."
required: false
default: ""
recover_timeout_s:
description: "Seconds to wait for the backend to answer after Collect models before failing the shard (#1011). Empty = 420, the shared default of ./.github/actions/wait-for-backend (#1045)."
required: false
default: ""
permissions:
issues: write
contents: write
# Pull the private ollama-e2e service image from GHCR with GITHUB_TOKEN.
# An explicit permissions block defaults every unlisted scope to `none`,
# so without this the container pull is denied (see #594).
packages: read
# Authenticate the Flakiness.io Playwright reporter via GitHub OIDC.
id-token: write
jobs:
prep:
name: Prepare shard matrix
# Runs inside the Playwright image so `--list` has the pinned runner without a
# browser download (the GCS leg the runners cannot complete — see #346). No
# Langflow service is needed: `--list` collects tests, it does not execute them.
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.2-noble
outputs:
# `matrix` is the strategy.matrix.include array: one entry per shard, each
# carrying its explicit space-separated spec-file list (issue #936).
matrix: ${{ steps.mk.outputs.matrix }}
shard_total: ${{ steps.mk.outputs.shard_total }}
steps:
- uses: actions/checkout@v7
- name: Install dependencies
run: npm ci
# Duration-balanced sharding (#936). Native `--shard=i/N` splits by test
# COUNT and piles the heavy real-LLM specs onto one shard that then runs ~2x
# longer against the single serialized Langflow backend (the load-timeout
# root cause tracked in #773). Instead we enumerate the current @stable spec
# files (authoritative — handles added/removed specs) and LPT bin-pack them
# by their committed historical durations (reports/spec-durations.json). Cold
# start / missing durations: the script degrades to a file-COUNT balance.
- name: Compute duration-balanced shard matrix
id: mk
shell: bash
env:
SHARDS: ${{ inputs.shards || '4' }}
run: |
N="$SHARDS"
case "$N" in ''|*[!0-9]*) N=4 ;; esac # non-numeric → default 4
if [ "$N" -lt 1 ]; then N=4; fi
npx playwright test --grep "@stable" --list --reporter=json > /tmp/stable-list.json
MATRIX="$(node scripts/partition-shards.mjs matrix /tmp/stable-list.json reports/spec-durations.json "$N")"
# Extract just the include array for strategy.matrix.include.
INCLUDE="$(node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(JSON.stringify(JSON.parse(d).include)))' <<<"$MATRIX")"
echo "matrix=$INCLUDE" >> "$GITHUB_OUTPUT"
echo "shard_total=$N" >> "$GITHUB_OUTPUT"
test:
name: "Shard ${{ matrix.shard }}/${{ needs.prep.outputs.shard_total }} (${{ inputs.langflow_image || 'langflowai/langflow-nightly' }}:${{ inputs.langflow_image_tag || 'latest' }})"
needs: prep
runs-on: ubuntu-latest
timeout-minutes: 90
strategy:
fail-fast: false
# One job per shard, each carrying its own spec-file list from the
# duration-balanced partition (prep.outputs.matrix). `matrix.shard` is the
# 1-based index; `matrix.files` is the space-separated spec-file list (#936).
matrix:
include: ${{ fromJSON(needs.prep.outputs.matrix) }}
outputs:
langflow_version: ${{ steps.lfver.outputs.version }}
# Run inside the official Playwright image: Chromium + all OS deps are
# pre-installed and pulled from mcr.microsoft.com (reachable from the
# runners), so we no longer download the browser from Google Cloud Storage
# — the leg that the runners cannot complete (see issue #346). The tag MUST
# match the pinned @playwright/test version in package.json.
container:
image: mcr.microsoft.com/playwright:v1.58.2-noble
services:
langflow:
image: ${{ inputs.langflow_image || 'langflowai/langflow-nightly' }}:${{ inputs.langflow_image_tag || 'latest' }}
ports:
- 7860:7860
env:
LANGFLOW_AUTO_LOGIN: "true"
LANGFLOW_SUPERUSER: langflow
LANGFLOW_SUPERUSER_PASSWORD: langflow123
# Cap the backend to one worker. Langflow's image default is (2*cpu)+1
# workers, each holding full in-memory state; under the collect-models
# load on the runner they contend until requests hang (#773). The launch
# scripts have defaulted to 1 since #888, and #923 applied the same cap
# to pr-validation.yml's service container — but that fix never reached
# THIS workflow, so the daily kept running ~5 workers on a 2-core runner
# that also hosts the Playwright container, Ollama and go-httpbin. That
# is the one place the known hardening was missing when run 30351107916
# (2026-07-28) wedged on every shard (#1011).
# Necessary but NOT sufficient on its own: #927 proved the wedge is
# process-wide, not worker contention. The retry cap and the health gate
# below are the other two halves.
LANGFLOW_WORKERS: "1"
# Cap how long ONE wedge can cost (#1048). Langflow's default is 300 s
# (`worker_timeout` in lfx runtime settings), handed straight to
# gunicorn's `timeout`. The worker class is LangflowUvicornWorker — an
# ASYNC worker — so that timeout is a heartbeat watchdog on the event
# loop, NOT a per-request deadline: it fires when the loop stops ticking
# (the #922/#927 wedge). Build DURATION cannot trip it — a component's
# sync method runs off the loop in a thread (`asyncio.to_thread` in
# `custom_component/component.py`, `_get_output_result`), so even a
# blocking provider call keeps the heartbeat ticking and an 8-minute
# live-LLM build is unaffected.
#
# Langflow's own docs contradict this: `deployment-multi-worker.mdx`
# calls the value "how long a worker may handle a single request" and
# says to RAISE it for long agent runs (its heavy-agent profile uses
# 600). The code above does not support that reading. Do NOT restore a
# higher value on the strength of those docs.
#
# Why lower it: a wedged worker does not recover on its own — in run
# 30410211167 gunicorn's kill is what restored service, twice, each
# time a full 300 s after the loop stopped (one block began within
# seconds of a failing Google call at 00:17:28 and was killed at
# 00:22:28). Waiting the default out buys nothing and every second of
# it is served to unrelated specs as a dead backend.
#
# What 120 buys, precisely: the kill lands 60-120 s after the loop
# stops, not at a fixed 120 — gunicorn hands the worker `timeout / 2`
# and uvicorn refreshes the heartbeat only that often, so detection
# costs up to one notify interval. The default's equivalent band is
# 150-300 s.
#
# Measured, not assumed: a probe container of this image started with
# LANGFLOW_WORKER_TIMEOUT=5 boots and serves /api/v1/version normally.
# Langflow's heavy init (components, starter projects, DB) runs in the
# PARENT process and gunicorn is launched afterwards with the app already
# built (`__main__.py`, progress step 6), so this ceiling does not bound
# startup — only a worker whose loop stops ticking once it is serving.
# Rollback is this one value.
LANGFLOW_WORKER_TIMEOUT: "120"
# The nightly image ships LANGFLOW_ALLOW_CUSTOM_COMPONENTS=false (a
# security default): custom-component creation is disabled, which
# hides the sidebar "New Custom Component" button and makes
# POST /api/v1/custom_component return 403. Enable it so the
# custom-component @stable specs (full-custom-component,
# customComponentAdd, api-custom-component-creation) exercise the
# feature instead of failing on the disabled surface.
LANGFLOW_ALLOW_CUSTOM_COMPONENTS: "true"
# A2A is off by default (lfx `a2a_enabled=False`) and its router is ALWAYS
# mounted, so with the flag off the three /api/v1/a2a/* routes answer 404 —
# indistinguishable from "not mounted". Enable it so the
# core-functionality/a2a specs exercise the surface instead of asserting
# against a disabled one, which would pass while testing nothing (#1240;
# surface scoped in #1195).
LANGFLOW_A2A_ENABLED: "true"
# Keep tracing ON: the @stable observability/traces specs probe
# /api/v1/monitor/traces, which is populated by the internal native
# tracer. That tracer's worker never starts when tracing is
# deactivated, so disabling it makes those specs fail deterministically
# (see #352). External tracers (LangSmith/Langfuse/etc.) stay dormant
# here because their API keys are absent.
LANGFLOW_DEACTIVATE_TRACING: "false"
# Enforce SQLite foreign keys (OFF by default in Langflow). Without
# this, cascade/orphan bugs like #13955 (bulk trace delete vs. the
# span->trace FK) are invisible — the raw DELETE "succeeds" leaving
# orphaned rows, so traces-delete-cascade.spec.ts and any future
# cascade guard would pass for the wrong reason. The dict replaces the
# product default wholesale, so the default pragmas are repeated here.
LANGFLOW_SQLITE_PRAGMAS: '{"synchronous": "NORMAL", "journal_mode": "WAL", "busy_timeout": 30000, "foreign_keys": "ON"}'
# SSRF allowlist. Two consumers:
# - "ollama": the Ollama component's model-list fetch targets the
# sibling service by hostname; without it the nightly's SSRF
# protection 400s the private address — see ollama-provider.spec.ts (#583).
# - private CIDRs: the api-request-component specs point at the
# self-hosted go-httpbin service (below) by its container IP (the
# component's validators.url() rejects the single-label service
# name, so ECHO_BASE_URL must be a raw IP). The IP is whatever the
# Docker network assigns, so we pre-authorize the RFC-1918 ranges
# rather than a fixed address — the SSRF layer matches the resolved
# private IP against these CIDRs and skips its private-IP block
# (#462). This lets the echo-dependent tests run against a reliable
# in-CI endpoint instead of the flaky public postman-echo.
LANGFLOW_SSRF_ALLOWED_HOSTS: "ollama,172.16.0.0/12,10.0.0.0/8,192.168.0.0/16"
options: >-
--health-cmd "curl -f http://localhost:7860/health_check || exit 1"
--health-interval 15s
--health-timeout 10s
--health-retries 10
--health-start-period 90s
# Local Ollama for ollama-provider.spec.ts (§7.6), test model BAKED
# into the image (build-ollama-image.yml) — no per-run model pull. The
# tests skip with a reason if the service is absent/unreachable.
ollama:
image: ghcr.io/${{ github.repository }}/ollama-e2e:llama3.2-1b
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
ports:
- 11434:11434
options: >-
--health-cmd "ollama list || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 10
--health-start-period 20s
# Self-hosted echo endpoint for the API Request component specs — replaces
# the public postman-echo.com the suite defaulted to, which hard-fails the
# daily on external outages (#462, prior recurrences #383/#407). go-httpbin
# is an httpbin-compatible echo (paths /get, /post, /put, /patch, /delete,
# /status/{code}, query + Host/url echo) reached BY LANGFLOW at its
# container IP (resolved in the step below). Public GHCR image, pinned by
# tag; pulled anonymously (no Docker Hub rate limits). No healthcheck: the
# scratch-based image ships only the Go binary (no shell/curl), so the
# "Resolve go-httpbin endpoint" step below both waits for and verifies it.
# Tag has NO `v` prefix: mccutchen/go-httpbin dropped the `v` from its GHCR
# tags at 2.17 (the `v`-prefixed series stops at v2.16.1), so `v2.23.1`
# returns `manifest unknown` and fails container init before any test runs.
# The pullable tag is the unprefixed `2.23.1` (#639).
go-httpbin:
image: ghcr.io/mccutchen/go-httpbin:2.23.1
ports:
- 8080:8080
# This job runs INSIDE the Playwright container, so both the test process
# and Langflow reach Ollama via the job network's service hostname (no
# localhost port mapping in here — unlike manual.yml/nightly.yml).
env:
OLLAMA_BASE_URL: http://ollama:11434
OLLAMA_BASE_URL_FROM_LANGFLOW: http://ollama:11434
OLLAMA_TEST_MODEL: llama3.2:1b
# The token sidecar's bounds, at JOB level because TWO steps read them: the
# poller below and the Playwright step, where the attribution sidecar runs inside
# `deleteFlow`'s teardown hook. They used to live in the poller step's own
# `env:`, which does not cross into another step -- so the sidecar never saw
# either one and silently used its hard-coded defaults. The numbers happened
# to match, so nothing was wrong in the run and nothing could be seen in a
# diff; the only symptom was a knob that did not turn, precisely when a
# wedged monitor endpoint made someone want to turn it.
#
# Defined ONCE, here, so the poller and the sidecar cannot end up bounded by
# different numbers. Pinned by scripts/token-sidecar-knobs.test.mjs.
TOKENS_TIMEOUT_MS: "8000"
TOKENS_DETAIL_CAP: "25"
# Per-CALL wall-clock ceiling for the attribution sidecar. Wired AHEAD of its
# reader on purpose: the budget itself lands with #1217's `deleteFlow` hook,
# and adding it from that PR would mean touching pr-validation.yml from a
# spec change, which flips that lane's coverage verdict to `canary` and drops
# its impacted-spec run. An unread variable costs nothing; a lane that cannot
# turn the knob when a monitor endpoint wedges costs a shard. With the two
# above it bounds a call at budget + timeout = 23s instead of
# cap x timeout = 208s. Read by the sidecar only; the poller has its own
# TOKENS_MAX_SECONDS.
TOKENS_BUDGET_MS: "15000"
steps:
- uses: actions/checkout@v7
# No actions/setup-node: the Playwright image already ships the Node
# toolchain it was built against, so we use it directly instead of
# layering a second Node on top.
- name: Install dependencies
run: npm ci
# Guard: the @playwright/test version (from npm) MUST equal the container
# image tag, or the runner looks for a browser revision the image doesn't
# ship and every test fails at launch with a cryptic error. Fail fast with
# a clear message instead. Keep PLAYWRIGHT_VERSION in sync with the
# container: image tag above.
- name: Verify Playwright version matches the container image
env:
PLAYWRIGHT_VERSION: "1.58.2"
run: |
NPM_VERSION="$(node -p "require('@playwright/test/package.json').version")"
if [ "$NPM_VERSION" != "$PLAYWRIGHT_VERSION" ]; then
echo "::error::@playwright/test is $NPM_VERSION but the job runs in mcr.microsoft.com/playwright:v$PLAYWRIGHT_VERSION. Bump the container image tag and package.json together."
exit 1
fi
echo "Playwright $NPM_VERSION matches the container image v$PLAYWRIGHT_VERSION."
# No "Install Playwright browsers" step: Chromium ships in the container
# image. npm ci installs the @playwright/test runner, whose version is
# pinned to EXACTLY 1.58.2 in package.json to match the image tag above,
# so the browser revision lines up. Bump both together when upgrading.
# The async Clipboard API (and other secure-context-gated browser APIs)
# only exist on a secure context. Chromium treats http://localhost as
# secure but NOT the service hostname http://langflow. Inside a job
# container the Langflow service is only reachable as http://langflow:7860,
# so forward localhost:7860 -> langflow:7860 and keep PLAYWRIGHT_BASE_URL
# on http://localhost:7860 — exactly as on ubuntu-latest. See issue #346.
- name: Forward localhost:7860 to the Langflow service
# Force bash: inside the container the default shell is `sh` (dash),
# which lacks the `disown` builtin used below.
shell: bash
run: |
apt-get update -qq && apt-get install -y -qq socat
nohup socat TCP-LISTEN:7860,fork,reuseaddr TCP:langflow:7860 >/tmp/socat.log 2>&1 &
disown
for i in $(seq 1 15); do
if curl -sf http://localhost:7860/health_check >/dev/null 2>&1; then
echo "Forward localhost:7860 -> langflow:7860 is up."
exit 0
fi
sleep 1
done
echo "::error::Port forward to langflow:7860 did not come up"
cat /tmp/socat.log || true
exit 1
# Tag the Flakiness.io upload with the ACTUAL Langflow version under test.
# We test against nightly:latest, whose tag never changes but whose real
# version bumps every night (1.11.0.devN, devN+1, ...). The
# @flakiness/playwright reporter reads FK_ENV_* env vars (prefix stripped,
# key lowercased) as the run's "environment", so exporting
# FK_ENV_langflow_version HERE — before the test step — makes the dashboard
# keep a separate history per resolved version and lets us pinpoint which
# nightly introduced a regression. Runs after the port-forward health check
# (localhost:7860 is up) and writes to $GITHUB_ENV so the test step inherits
# it. Fail-soft: if the version can't be read, the run still uploads, just
# without the tag. (Mirrors the parser in the post-run "Resolve Langflow
# version" step, which feeds the run summary.)
- name: Tag Flakiness environment with Langflow version
shell: bash
run: |
V="$(curl -sf --connect-timeout 5 --max-time 15 http://localhost:7860/api/v1/version \
| node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{process.stdout.write((JSON.parse(d).version||"").toString())}catch{process.stdout.write("")}})' \
2>/dev/null || true)"
if [ -n "$V" ]; then
echo "FK_ENV_langflow_version=$V" >> "$GITHUB_ENV"
echo "Tagged Flakiness environment: langflow_version=$V"
else
echo "::warning::Could not resolve Langflow version; Flakiness upload will be untagged."
fi
# Resolve the go-httpbin service to the IP Langflow must call. The API
# Request component runs validators.url() on the URL and REJECTS a
# single-label host (http://go-httpbin:8080 fails), but ACCEPTS a raw IP —
# so ECHO_BASE_URL is built from the container IP, not the service name.
# The IP is pre-authorized by the RFC-1918 CIDRs in
# LANGFLOW_SSRF_ALLOWED_HOSTS above, and is exported to $GITHUB_ENV so the
# test step (and collect-models) inherit it.
#
# The logic moved into a shared action (#1128): it lived here only, so
# pr-validation, nightly and manual kept calling the public host and reding
# on its outages. `in_container: true` because this job runs INSIDE the
# Playwright image and can resolve the service alias — the host-based lanes
# cannot, and read the IP from `docker inspect` instead.
#
# `mode: warn` keeps THIS lane's behaviour exactly as it was: fail-soft, on
# the same reasoning as `Collect models` below (#980) — a day of coverage
# for the dozens of specs that never touch the echo outweighs strictness.
# The PR/nightly/manual lanes use `fail`, where a silent public fallback
# would make a third party's outage read as a product failure.
- name: Resolve go-httpbin endpoint
uses: ./.github/actions/resolve-echo-endpoint
with:
mode: warn
in_container: "true"
# `continue-on-error` stays: a drained provider key must not kill a day of
# coverage for the dozens of specs that never touch that provider — the
# lesson of #980, where a strict gate killed all four shards over a drained
# Anthropic account. A red step here is therefore EXPECTED to be survivable,
# and the shard deliberately does NOT abort (see the health gate below,
# which is what actually protects the run).
- name: Collect models
id: collect_models
run: npx playwright test tests/collect-models.spec.ts --reporter=line
# NOT a hard gate here, deliberately, and the asymmetry with
# pr-validation.yml is intentional rather than an oversight (#1370 asked
# for it in writing). There this step gates the job: a red sweep means
# the impacted specs never run, which is right, because a spec that needs
# a model must not run without one (#1152). Here a red sweep costs the
# provider's parametrized specs and nothing else, and losing a scheduled
# day of @stable coverage to a drained key is the worse trade (#980).
#
# COLLECT_REQUIRED_PROVIDERS is therefore deliberately UNSET below: this
# lane rotates providers by weekday (#1185) and owes multi-provider
# coverage, so every env-keyed provider stays required. Narrowing it is
# the PR lane's move, and only because that lane pins itself to one.
continue-on-error: true
env:
CI: "true"
PLAYWRIGHT_BASE_URL: "http://localhost:7860/"
# NO RETRIES for this step (#1011). CI's default is 2, so a failing
# collect-models ran THREE full attempts, each re-importing every key and
# re-walking the Model Providers UI against the single backend. On run
# 30351107916 that turned a ~49 s step into 7-12 min of sustained load
# and wedged the gunicorn worker; the next step's globalSetup preflight
# then found nothing answering and every shard exited with 0 tests.
# Retrying does not add information here — the first attempt already
# produced the diagnosis, and the step is allowed to fail. The cost of a
# transient flake is that provider specs skip this run (pre-existing
# behaviour, and #570's gate reports it); the cost of retrying is the
# whole run.
PLAYWRIGHT_RETRIES: "0"
# This run is what IMPORTS the provider credentials into Langflow, so
# the pre-flight credential check (globalSetup, #884) must not fire
# here — it would fail on the very keys this step is about to set.
PREFLIGHT_SKIP_CREDENTIALS: "1"
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
# Health gate between the two steps that share the Langflow container
# (#1011). collect-models' model-toggle sweep can leave the backend
# process-wide WEDGED — container alive, event loop blocked, requests simply
# not answered (#922, #927). globalSetup polls for 120 s and then throws, so
# a wedge that outlasts that window costs the ENTIRE shard and reports itself
# as a preflight error with no mention of what caused it. The gate does two
# things the preflight cannot: it gives the wedge a longer window to clear
# BEFORE Playwright starts, and when it does not clear it fails with the real
# cause named, in its own step, instead of 40 specs' worth of container time
# and a globalSetup stack trace.
#
# Shared implementation since #1045 — this was the first of four copies (see
# the action for what diverged and why one of them lacked a gate entirely).
# Two things changed for the daily when it adopted the shared version, both
# ported from pr-validation's copy: the failure now names WHICH state it
# observed (refused → dead vs accepted-and-unanswered → the #922/#927 wedge)
# rather than hedging "wedged or dead", and a heartbeat keeps a step that can
# legitimately sit for 5-7 min from reading as hung.
#
# Recovery is real and observed, not merely hoped for: on 2026-07-28 (#1019)
# the service container's log showed gunicorn raising WORKER TIMEOUT ~3.5 min
# after a sweep, SIGKILLing the worker, and the replacement serving requests
# 214 s after polling began — 94 s past globalSetup's 120 s budget. That is
# what the deadline is sized against; attribution is what the gate buys
# unconditionally, on every run, whether or not the backend comes back.
- name: Wait for the backend to recover from the collect-models load
uses: ./.github/actions/wait-for-backend
with:
# 420 s for every lane since #1045 (the number's evidence is in the
# action). The recover_timeout_s dispatch input still shortens it for a
# validation run without editing this file; empty on schedule → 420.
timeout_s: ${{ inputs.recover_timeout_s || '420' }}
next_step_label: "the @stable run"
attribution: "NOT a per-test failure"
# Reported, never gated on — a drained key must not kill unrelated
# specs (#980), but a silent provider skip must not go unexplained
# either (#570).
collect_models_outcome: ${{ steps.collect_models.outcome }}
# Mid-run backend liveness (#1030). The gate above only proves the backend
# is alive when Playwright STARTS. On the heavy shards the single worker
# keeps wedging DURING the run: on run 30444299314 gunicorn killed it 7
# times on shard 3 and 10 times on shard 4, spread evenly across ~30
# minutes of specs. Those kills are visible only in the service container
# log, and only 60-120 s AFTER the event loop actually stalled
# (LANGFLOW_WORKER_TIMEOUT=120, #1048) — so an outage window inferred from
# them is 60-120 s wide, and at that wedge frequency such windows cover
# 33-73% of the shard. Any failure "correlates" with one by chance, which
# is why the wedge kept costing a full triage cycle to attribute.
#
# Measuring from inside fixes the resolution: probe the same URL the specs
# use every 2 s while it answers — a failed probe burns its whole 4 s
# deadline, so the cadence relaxes to ~4 s exactly while down — and two
# consecutive failures bound an outage to ~4 s instead of 120.
#
# DIAGNOSTIC ONLY — it never fails a step and never aborts the shard.
# Aborting was this issue's original proposal and the data rejected it:
# shard 4 wedged ten times and still passed 101 specs, so an abort would
# discard far more coverage than it saves.
- name: Start the backend liveness recorder (shard ${{ matrix.shard }})
# Force bash for `disown` — the container's default shell is sh (dash).
# Same reason as the port-forward step above.
shell: bash
run: |
nohup node scripts/watch-backend.mjs > /tmp/liveness.log 2>&1 &
echo "$!" > /tmp/liveness.pid
disown
echo "Backend liveness recorder started (pid $(cat /tmp/liveness.pid))."
env:
WATCH_URL: http://localhost:7860/api/v1/version
WATCH_OUT: backend-liveness.jsonl
WATCH_INTERVAL_MS: "2000"
# A wedged worker ACCEPTS the connection and never answers, so the
# probe needs its own deadline — without it the recorder would hang
# exactly when it has something to record.
WATCH_TIMEOUT_MS: "4000"
# Backstop so a recorder that outlives its kill cannot idle for the
# job's whole lifetime. Well above any observed shard duration (~40 min)
# and BELOW the job's timeout-minutes: 90 — at 90 min it would only ever
# be reached after the runner had already killed the job, making it dead
# configuration rather than a backstop.
WATCH_MAX_SECONDS: "3600"
# Rotate the agent specs through ONE provider per run, by weekday (#1185).
# The parametrized specs resolve one model per ACTIVE provider, so ~30 @stable
# agent tests run an openai AND an anthropic AND a google variant every weekday —
# multi-turn agent runs with the tool schemas re-sent every turn (Langflow sets no
# `cache_control`, so nothing is cached on the anthropic side). `claude-sonnet-5`
# is $3/$15 per MTok against `gpt-4o-mini` at ~$0.15/$0.60: 20-25x per token for
# assertions that are about Langflow, not about the provider. The PR lane stopped
# paying that on 2026-07-31 (#1169 / PR #1170); this is the same argument applied
# to the lane #1170 deliberately left multi-provider.
#
# Coverage is not what is being cut. The provider-contract specs
# (core-functionality/model-provider/*-provider.spec.ts) read NEITHER pin
# variable, so every provider is still exercised end-to-end EVERY day — #1184
# added a unit test that fails if one of them starts reading one. What rotates is
# agent behaviour, which is a Langflow contract rather than a provider one.
#
# Rotation rather than a fixed pin, at identical cost: a fixed pin would make the
# detection window for a provider-specific regression a standing human decision
# (#643 anthropic streaming/`thinking`, #963 gemini "Message empty." — both real,
# both caught here). Rotating bounds it to <=3 days automatically. The weekday
# mapping is FIXED, not evenly distributed, so two Mondays are comparable:
# Mon openai · Tue anthropic · Wed google · Thu openai · Fri anthropic
#
# A drained key costs a DEVIATION, not the day: the script advances to the next
# active provider in the rotation and says so with a ::warning::. It declines to
# pin only when every provider is down — where the fallback is moot anyway — and
# exits 2 on a providers.json it cannot read (#1035). Losing coverage is the more
# expensive failure (#980), and it is not hypothetical: this lane recorded ZERO
# tests on 07-28 and 07-31.
#
# continue-on-error: the pin is an optimisation. If it fails outright, the right
# outcome is the costlier multi-provider run, not a lost day — the same trade
# `Collect models` above already makes.
- name: Rotate the lane to this weekday's provider
continue-on-error: true
run: node scripts/select-daily-model-target.mjs
# In-run token consumption recorder (#1197). Langflow computes what each flow
# run cost in tokens and discards it: deleting a flow 404s its trace, and this
# suite deletes every flow it creates. So the only place to read it is during
# the run. One request per tick — /api/v1/monitor/traces answers without a
# flow_id — plus one detail fetch per new trace, capped per tick so a burst
# cannot pile load onto the single backend (#817/#1048).
#
# DIAGNOSTIC ONLY: it never fails a step and never aborts the shard.
- name: Start the token consumption recorder (shard ${{ matrix.shard }})
shell: bash
continue-on-error: true
run: |
nohup node scripts/watch-tokens.mjs > /tmp/tokens.log 2>&1 &
echo "$!" > /tmp/tokens.pid
disown
echo "Token recorder started (pid $(cat /tmp/tokens.pid))."
env:
TOKENS_BASE_URL: http://localhost:7860
TOKENS_OUT: token-probes-${{ matrix.shard }}.jsonl
TOKENS_INTERVAL_MS: "15000"
# TOKENS_TIMEOUT_MS and TOKENS_DETAIL_CAP are deliberately NOT here: they
# are defined once at job level, because the attribution sidecar in the
# Playwright step reads them too and a step-level env: does not reach it.
# Same reasoning as WATCH_MAX_SECONDS: below the job's timeout-minutes so
# it is a real backstop rather than dead configuration.
TOKENS_MAX_SECONDS: "3600"
- name: Run @stable tests (shard ${{ matrix.shard }})
# Sharded run. The reporter list is NOT overridden on the CLI: setting
# PW_SHARD_FILE_LEVEL selects the sharded reporter shape in
# playwright.config.ts — `blob` (the merge job rebuilds html/github/json
# from the combined blobs) PLUS the Flakiness.io reporter. A CLI
# `--reporter=blob` would replace the whole config list and drop the
# Flakiness uploader; keeping it in config lets each shard upload its own
# slice in the reporter's onExit() (per-run upload — no Flakiness merge
# needed). OIDC auth for the upload comes from the workflow-level
# `id-token: write` permission, inherited by this job.
# Duration-balanced sharding (#936): this shard runs the explicit spec-file
# list computed by prep (matrix.files), NOT Playwright's `--shard=i/N`
# count-split. `--grep @stable` still scopes to the stable tests within
# those files; `--pass-with-no-tests` tolerates an empty shard (N > files).
run: npx playwright test --grep "@stable" --pass-with-no-tests ${{ matrix.files }}
env:
CI: "true"
PW_SHARD_FILE_LEVEL: "1"
# Name each shard's Flakiness.io upload so the dashboard can tell the
# slices apart (per-test history still aggregates across shards by test).
FLAKINESS_TITLE: "Shard ${{ matrix.shard }}/${{ needs.prep.outputs.shard_total }}"
# Manual-dispatch retries override (empty on schedule → config default).
PLAYWRIGHT_RETRIES: ${{ inputs.retries }}
PLAYWRIGHT_BASE_URL: "http://localhost:7860/"
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
# Azure AI Foundry (§7.8, #1194 → #1270). Three variables because it is
# the first provider on the Model Providers page configured by a PAIR
# (key + the portal's OpenAI-compatible endpoint), plus the DEPLOYMENT
# NAME its inference is addressed by — a portal name, not a catalog id,
# which is the whole point of that coverage.
#
# Read ONLY here, never by the `Collect models` step above: Azure is
# deliberately absent from `providerConfigMap` (a two-variable provider
# does not fit its `api-key | base-url` union — see
# tests/helpers/provider-setup/provider-config.ts), so the sweep neither
# imports nor validates these. Absent, the spec's own probe skips its two
# credential tests with the concrete reason; the other four run regardless.
AZURE_AI_FOUNDRY_API_KEY: ${{ secrets.AZURE_AI_FOUNDRY_API_KEY }}
AZURE_AI_FOUNDRY_ENDPOINT: ${{ secrets.AZURE_AI_FOUNDRY_ENDPOINT }}
AZURE_AI_FOUNDRY_TEST_DEPLOYMENT: ${{ secrets.AZURE_AI_FOUNDRY_TEST_DEPLOYMENT }}
# Turns the cleanup sidecar on: with TOKENS_ATTRIB unset the helper makes
# no request and writes no file, which is every LOCAL run and nothing
# else. NOT "the PR lane" — pr-validation.yml:711 sets it too, on purpose,
# because the PR lane is the one place a teardown-cost regression is
# catchable before merge (manual.yml:346 sets it as well). That stale
# parenthetical was copied into a design as a project-wide constraint;
# state which lanes set it, never guess from here.
TOKENS_ATTRIB: token-attrib-${{ matrix.shard }}.jsonl
# always(): the liveness data is most valuable precisely when the shard
# went red, and a shard killed by its own timeout still leaves a probe log
# worth summarizing. WATCH_FILES carries this shard's spec list into the
# summary because the MERGED report has no shard column — without it the
# merge job could blame shard 3's outage for a shard-4 failure (#1030).
- name: Summarize backend liveness (shard ${{ matrix.shard }})
if: always()
shell: bash
run: |
# SIGTERM is the recorder's normal exit path: it stops after the
# current probe. `|| true` so a recorder that already died (or never
# started) cannot fail this step.
if [ -f /tmp/liveness.pid ]; then
kill "$(cat /tmp/liveness.pid)" 2>/dev/null || true
fi
# Let the in-flight probe land before reading the log — a probe can be
# mid-append when the signal arrives.
sleep 3
mkdir -p liveness
node scripts/watch-backend.mjs --summarize
# Ship the raw probe log too: the summary answers "when and how long",
# the log answers "what did the backend say", which is what a forensic
# pass on a new wedge shape needs.
cp backend-liveness.jsonl liveness/ 2>/dev/null || true
echo "--- recorder stdout (tail) ---"
tail -n 5 /tmp/liveness.log 2>/dev/null || true
env:
WATCH_OUT: backend-liveness.jsonl
WATCH_SUMMARY: liveness/backend-liveness.json
WATCH_LABEL: ${{ matrix.shard }}
WATCH_FILES: ${{ matrix.files }}
- name: Upload backend liveness (shard ${{ matrix.shard }})
uses: actions/upload-artifact@v7
if: always()
with:
name: liveness-${{ matrix.shard }}
path: liveness/
retention-days: 7
# NOT `error` (unlike the blob upload): a shard that died before the
# recorder ever wrote a line has no liveness data, and a missing
# diagnostic must not turn into a red step. The merge job reports the
# absence as `measured=false`, which it renders as UNKNOWN — never as
# a healthy backend.
if-no-files-found: warn
# Renamed (#1197 review, minor fix) from a name that claimed this step
# summarizes token consumption — it does not; it only stops the recorder
# and copies its files. The actual pricing/summarizing happens once, in
# the merge job's own step further down this workflow.
- name: Stop and collect token consumption (shard ${{ matrix.shard }})
if: always()
continue-on-error: true
shell: bash
run: |
if [ -f /tmp/tokens.pid ]; then
kill "$(cat /tmp/tokens.pid)" 2>/dev/null || true
fi
# Let an in-flight append land before the file is copied. 3s was shorter
# than a worst-case in-flight tick (TOKENS_DETAIL_CAP × TOKENS_TIMEOUT_MS
# = 25 × 8s = 200s if every detail fetch times out sequentially), and the
# LAST tick holds the newest traces — the ones most likely to matter for
# an anomaly (#1197 review, minor fix). A full worst-case wait is too
# costly to pay on every shard; 10s is a modest bump that catches an
# ordinary slow tick without meaningfully lengthening the job.
sleep 10
mkdir -p tokens
cp token-probes-${{ matrix.shard }}.jsonl tokens/ 2>/dev/null || true
cp token-attrib-${{ matrix.shard }}.jsonl tokens/ 2>/dev/null || true
# §6.4: the provider this shard actually resolved, carried to the merge
# job. $GITHUB_ENV reaches later steps of THIS job only, and the POST
# lives in the merge job -- so the value rides the artifact that already
# crosses that boundary. Written unconditionally: an EMPTY file means
# the rotation declined to pin (every provider dry), which is a real
# outcome and must not be confused with a shard that failed to report.
printf '%s' "${MODEL_TEST_PROVIDER:-}" > "tokens/token-provider-${{ matrix.shard }}.txt"
echo "--- token recorder stdout (tail) ---"
tail -n 5 /tmp/tokens.log 2>/dev/null || true
- name: Upload token consumption (shard ${{ matrix.shard }})
uses: actions/upload-artifact@v7
if: always()
continue-on-error: true
with:
name: tokens-${{ matrix.shard }}
path: tokens/
if-no-files-found: ignore
# Resolve the ACTUAL Langflow version running in the service container.
# The image tag is just `:latest` (or an RC/stable tag), so it never
# carries the concrete nightly build (e.g. 1.11.0.dev25). Ask the running
# service via its public /api/v1/version endpoint (AUTO_LOGIN is on, so it
# needs no auth) and feed it into the payload so the QA Platform's Run
# Summary can show the exact version tested. Best-effort: never fail the run.
- name: Resolve Langflow version
if: always()
id: lfver
continue-on-error: true
run: |
# --connect-timeout/--max-time so a slow/hung service can't stall the
# job; tr strips any CR/LF so the value stays a single line and never
# corrupts the key=value $GITHUB_OUTPUT format.
V="$(curl -sf --connect-timeout 5 --max-time 15 http://localhost:7860/api/v1/version \
| node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{process.stdout.write((JSON.parse(d).version||"").toString())}catch{process.stdout.write("")}})' \
| tr -d '\r\n')"
echo "Resolved Langflow version: '${V:-<none>}'"
echo "version=$V" >> "$GITHUB_OUTPUT"
- name: Upload blob report (shard ${{ matrix.shard }})
uses: actions/upload-artifact@v7
if: always()
with:
name: blob-${{ matrix.shard }}
path: blob-report/
retention-days: 7
if-no-files-found: error
merge:
name: Merge shard reports & report
needs: [prep, test]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
issues: write
contents: write
container:
image: mcr.microsoft.com/playwright:v1.58.2-noble
steps:
- uses: actions/checkout@v7
- name: Install dependencies
run: npm ci
- name: Download all shard blobs
uses: actions/download-artifact@v8
with:
pattern: blob-*
path: all-blobs
# Flatten every blob-<shard> artifact into all-blobs/ directly.
# Without this, each artifact lands in its own all-blobs/blob-<shard>/
# subdir and `merge-reports ./all-blobs` finds no report files (it does
# not recurse). The per-shard blob zips are uniquely named
# (report-<hash>-<shard>.zip), so flattening cannot collide.
merge-multiple: true
# Liveness summaries from every shard (#1030). Deliberately WITHOUT
# merge-multiple: each shard names its file `backend-liveness.json`, so
# flattening would have them overwrite each other. The reader recurses into
# the per-artifact subdirectories instead.
# continue-on-error: no shard ever uploading liveness data (an old shard
# job, a run that died in prep) must not fail the merge — the reporter
# renders that as UNKNOWN, which is the honest verdict.
- name: Download shard liveness data
uses: actions/download-artifact@v8
if: always()
continue-on-error: true
with:
pattern: liveness-*
path: all-liveness
# Token consumption artifacts from every shard (#1197). Flattened like the
# blob download above: each shard names its files uniquely
# (token-probes-<shard>.jsonl / token-attrib-<shard>.jsonl /
# token-provider-<shard>.txt), so merge-multiple cannot collide them.
# NOTE: this `path:` is the value merge-token-payload.mjs is given as
# TOKENS_DIR — the provider files are read from here, so the two must agree.
# continue-on-error: same reasoning as the liveness download — a shard that
# never uploaded (an old job, a run that died in prep) must not fail the merge.
- name: Download shard token consumption data
uses: actions/download-artifact@v8
if: always()
continue-on-error: true
with:
pattern: tokens-*
path: all-tokens
merge-multiple: true
- name: Guard — every expected shard produced a blob
id: shardguard
# always(): this guard's output gates the @stable auto-removal and the
# incomplete-run issue, so it must be SET even when the download step
# above failed — which is exactly what happens when every shard died
# before producing a blob (e.g. all four failed the post-collect-models
# health gate, #1011) and the artifact pattern matched nothing. Skipping
# it there left `complete` empty: neither 'true' nor 'false', so the run
# reported as a plain per-test failure with no under-count attribution.
if: always()
shell: bash
run: |
EXPECTED="${{ needs.prep.outputs.shard_total }}"
# Tolerate a missing directory for the same reason: with zero artifacts
# downloaded, all-blobs/ may not exist and `find` would fail the step
# under `bash -eo pipefail`, re-opening the hole always() just closed.
mkdir -p all-blobs
# After merge-multiple, blobs are flat *.zip files (one per shard), not subdirs.
FOUND="$(find all-blobs -maxdepth 1 -name '*.zip' | wc -l | tr -d ' ')"
# An empty (or non-numeric) shard_total means `prep` ITSELF failed, so the
# run never got a matrix and no shard could have produced anything — the
# most incomplete a run can be. Decide that explicitly, because the
# comparison below silently calls it COMPLETE (#1024): `[ 0 -lt "" ]`
# errors with "integer expression expected", and a failing command inside
# an `if` condition is not fatal under `set -e`, so it falls through to
# the else. Defaulting EXPECTED to 0 does not help either — `0 -lt 0` is
# false and lands in the same else. Same shape as the hole always() closed
# in #1011: a guard asserting "complete" over a run that executed nothing.
case "$EXPECTED" in
''|*[!0-9]*)
echo "::warning::prep produced no usable shard_total ('$EXPECTED') — the run never got a shard matrix, so no shard could report ($FOUND blob(s) present). Treating the merged report as INCOMPLETE."
echo "complete=false" >> "$GITHUB_OUTPUT"
exit 0
;;
esac
echo "Expected $EXPECTED shard blobs, found $FOUND."
if [ "$FOUND" -lt "$EXPECTED" ]; then
echo "::warning::Only $FOUND/$EXPECTED shard blobs present — the merged report is INCOMPLETE (a shard died before producing a blob). Failures may be under-counted."
echo "complete=false" >> "$GITHUB_OUTPUT"
else
echo "complete=true" >> "$GITHUB_OUTPUT"
fi
- name: Merge blob reports
run: npx playwright merge-reports --reporter=html,github,json ./all-blobs > /dev/null
env:
PLAYWRIGHT_JSON_OUTPUT_NAME: results.json
PLAYWRIGHT_HTML_REPORT: playwright-report
# Second, independent guard (#1012). `shardguard` above answers "did every
# shard produce a blob"; this one answers "did the run produce any TEST" —
# a question a blob count cannot reach. On run 30351107916 (2026-07-28) all
# four shards aborted in globalSetup on the post-collect-models backend
# wedge (#1011) yet each still uploaded a valid, EMPTY blob: shardguard saw
# 4/4 and reported complete, the merged report held ZERO tests, and the
# umbrella issue rendered "No per-test @stable hard failures were
# auto-removed" — indistinguishable from a clean triage.
# An empty run is a DIFFERENT failure class from an incomplete merge (the
# merge was complete; the shards just never ran anything), so it gets its
# own output rather than being folded into `complete`. `always()` so a
# FAILED merge-reports step is caught too — the script treats a missing
# results.json as empty, because a guard must not go green because it could
# not look.
- name: Guard — merged report contains test results
id: runguard
if: always()
run: node scripts/check-run-integrity.mjs
env:
PLAYWRIGHT_JSON: results.json
# Name the wedge (#1030). Runs after the merge so it can attribute failing
# attempts to the outages measured on their OWN shard, and before the
# umbrella issue so the section can lead the body: a wedged run has to
# announce the wedge, not a list of collateral specs that reads as
# per-test rot.
#
# Reports only — it sets no gate. The @stable auto-removal below decides the
# tag on the failure's OWN error signature (#1031), not on this table: the
# honesty note in the section explains why overlap with an outage window is
# a lead and not a verdict. The only thing that crosses over is the `wedged`
# output, passed to the auto-remove action to word its exemption.
#
# continue-on-error: the two steps that follow and matter most on a red day
# — `Auto-remove @stable from hard failures` and `Create issue on failure` —
# carry NO always(), so they run under the implicit success() of every step
# before them. A throw in this diagnostic would therefore SKIP the umbrella
# issue it exists to improve. The script swallows its own errors too; this is
# the second layer, for the case the failure is the `node` invocation itself.
- name: Report mid-run backend outages
id: liveness
if: always()
continue-on-error: true
run: node scripts/report-backend-outages.mjs
env:
LIVENESS_DIR: all-liveness
PLAYWRIGHT_JSON: results.json
# Without the expected count the reporter can only speak about shards
# that uploaded data: "2 measured shard(s)" reads the same whether the
# run had 2 shards or 4, so a shard whose job died before writing a
# summary would disappear instead of reading as UNKNOWN.
SHARD_TOTAL: ${{ needs.prep.outputs.shard_total }}
- name: Upload Playwright report (full, heavy)
id: upload_report # ← full report: index.html + data/ + trace/ attachments (~380 MB)
uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report-daily-${{ github.run_id }}
path: playwright-report/
retention-days: 7
# Lightweight, self-contained report: index.html embeds the whole test tree,
# statuses, errors and steps inline (playwrightReportBase64), so it opens
# standalone without the heavy data/ + trace/ attachments (~1.5 MB vs ~380 MB).
# This is the artifact linked from the QA Platform (one-click, small download),
# and it gets the longest retention GitHub allows (90 days) since it's small.
- name: Upload report index (lightweight, long-lived)
id: upload_index # ← artifact-url fed to the QA Platform payload below
uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report-index-daily-${{ github.run_id }}
path: playwright-report/index.html
retention-days: 90
# Raw Playwright JSON report (the --reporter=json output produced by the
# "Merge blob reports" step above). Uploaded UNMODIFIED — no
# transform, no enrichment — as the machine-readable source of truth for
# downstream per-test import/analysis: each test's status, duration,
# retries (results[]), error, annotations and projectName plus the run's
# stats.startTime. 90-day retention (matching the lightweight index) so a
# late/backfill import can still reach it long after the run; the JSON is
# small, so the long window costs ~nothing.
- name: Upload Playwright JSON report
uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-json-daily-${{ github.run_id }}
path: results.json
retention-days: 90
# ── Record in the QA Platform DB: EVERY run (scheduled + manual dispatch).
# Not gated on `schedule`, so manual runs are recorded too. Coverage is
# best-effort; the POST is warning-only so a platform outage never fails
# the suite / artifact / issue. ──
- name: Compute coverage counts
if: always()
id: cov
continue-on-error: true
run: |
echo "stable=$(npx ts-node scripts/stable-tests.ts --count)" >> "$GITHUB_OUTPUT"
echo "total=$(grep -rE '^\s*test\s*\(' tests/tests-automations/regression --include='*.spec.ts' | wc -l | tr -d ' ')" >> "$GITHUB_OUTPUT"
- name: Build run payload
if: always()
env:
PLAYWRIGHT_JSON: results.json
WORKFLOW: ${{ github.event_name == 'schedule' && 'daily-stable' || 'daily-stable-manual' }}
GITHUB_RUN_ID: ${{ github.run_id }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
LANGFLOW_IMAGE: ${{ inputs.langflow_image || 'langflowai/langflow-nightly' }}:${{ inputs.langflow_image_tag || 'latest' }}
LANGFLOW_VERSION: ${{ needs.test.outputs.langflow_version }}
STABLE_COUNT: ${{ steps.cov.outputs.stable }}
TOTAL_COUNT: ${{ steps.cov.outputs.total }}
# Point the QA Platform at the lightweight index (one-click, small
# download), not the heavy full report — and match its 90-day retention.
EVIDENCE_URL: ${{ steps.upload_index.outputs.artifact-url }}
run: |
export EVIDENCE_EXPIRES_AT="$(date -u -d '+90 days' +%Y-%m-%dT%H:%M:%SZ)"
node scripts/build-run-payload.mjs > payload.json
echo "Payload:"; cat payload.json
- name: POST run to QA Platform
if: always()
continue-on-error: true # a platform failure must NOT bring down the suite / artifact / issue
env:
QA_PLATFORM_ENDPOINT: ${{ vars.QA_PLATFORM_ENDPOINT }}
QA_E2E_AUTOMATION_TOKEN: ${{ secrets.QA_E2E_AUTOMATION_TOKEN }}
run: |
if [ -z "$QA_PLATFORM_ENDPOINT" ] || [ -z "$QA_E2E_AUTOMATION_TOKEN" ]; then
echo "::warning::QA platform endpoint/token not configured — skipping POST."; exit 0; fi
code=$(curl -s -o /tmp/resp.json -w '%{http_code}' -X POST "$QA_PLATFORM_ENDPOINT" \
-H "Authorization: Bearer $QA_E2E_AUTOMATION_TOKEN" -H "Content-Type: application/json" \
--data @payload.json)
echo "HTTP $code"; cat /tmp/resp.json || true
case "$code" in 200|201) echo "Recorded.";; *) echo "::warning::QA platform POST failed ($code)";; esac