-
Notifications
You must be signed in to change notification settings - Fork 1
955 lines (893 loc) · 51.9 KB
/
Copy pathpr-validation.yml
File metadata and controls
955 lines (893 loc) · 51.9 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
name: PR Validation
on:
pull_request:
branches: [main]
jobs:
typecheck:
name: TypeScript Check
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: TypeScript check
run: npm run typecheck
# Unit tests for the dependency-free helpers under scripts/ — plain
# `node --test`, no Playwright and no backend, so they belong in this fast
# job rather than the E2E one. These cover CI-critical logic that had no
# test job at all before: the duration-balanced shard partitioner (#936)
# and the report-integrity guard (#1012). A guard whose own test never
# runs is a guard nobody is guarding.
- name: Script unit tests
run: npm run test:scripts
# The TypeScript half of the same idea (#1017). Everything written in `.ts`
# was ungated: the `@stable` parser two generators read as the release
# signal (#985), the script that EDITS SPECS AND COMMITS TO `main` with no
# human review, and the collect-models fallback whose scenario matrix was
# validated in a scratch file outside the repo (#1011).
#
# Same runner as the lane above — `node --test` — via ts-node's require
# hook, so no new dependency and no second runner to keep current. The hook
# (not the deprecated `--loader ts-node/esm`) is what makes this work on the
# Node 20 this workflow pins; the trade-off and the rejected options are
# recorded in CONTRIBUTING.md → "Unit tests".
- name: TypeScript unit tests
run: npm run test:units
# The REGRESSIONS.md indicator block is generated and committed by hand.
# Fail the PR when a row was added without regenerating it, so the
# headline number can never silently disagree with the table.
- name: Regression Ledger indicator in sync
run: npm run regressions:check
lint:
name: ESLint
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint:ci
# The repo's own Python code had no lane at all, which is how `generate_report.py`
# shipped a report that said `Result: PASSED` on a run whose UI phase had failed
# (#1120, run #115). The two `test:units` / `test:scripts` lanes only cover
# TypeScript and `.mjs`, so nothing here was verifiable before merge.
python-units:
name: Python unit tests
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Install pytest
run: pip install --quiet pytest
- name: Migration helper unit tests
run: |
# `test_ui_migration.py` is excluded, not missing: it drives a real browser
# against a migrated Langflow and belongs to migration-test.yml. Everything
# else under this path is a plain unit test, so a new one is picked up
# without touching this workflow.
#
# Fail loudly on an empty selection, like the two Node lanes do — a unit
# lane that silently runs nothing is worse than no lane (#1012's rule).
COUNT=$(python -m pytest tests/github-workflows/migration \
--ignore=tests/github-workflows/migration/test_ui_migration.py \
--collect-only -q 2>/dev/null | grep -cE '::' || true)
if [ "${COUNT:-0}" -eq 0 ]; then
echo "::error::no Python unit tests were collected — this lane would have passed without verifying anything."
exit 1
fi
echo "collected $COUNT Python unit test(s)"
python -m pytest tests/github-workflows/migration \
--ignore=tests/github-workflows/migration/test_ui_migration.py -v
checklist-guard:
name: QA-CHECKLIST guard
runs-on: ubuntu-latest
timeout-minutes: 6
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: "20"
cache: "npm"
# Fail the PR if it edits the AUTO-GENERATED blocks of QA-CHECKLIST.md
# (Coverage Summary table + note, Phase 0 list, Phase 1/2 tables). Those
# regenerate on merge via update-coverage-summary.yml; PRs must edit only
# the manual Part II bullets, so concurrent PRs don't collide on the
# generated count lines (issue #741). No npm install needed — pure git+node,
# so it runs before the install step and reports fast.
- name: Guard QA-CHECKLIST.md generated blocks
env:
BASE_REF: ${{ github.base_ref }}
run: |
git fetch origin "$BASE_REF" --quiet
node scripts/check-checklist-guard.mjs "origin/$BASE_REF"
# The coverage guard below parses `@stable` out of the spec ASTs, so it
# needs ts-node + typescript. `--ignore-scripts` skips Playwright's
# browser download — nothing here drives a browser.
- name: Install dependencies
run: npm ci --ignore-scripts
# Fail the PR when a spec CLAIMS coverage (carries `@stable`, or has a spec
# doc under docs/) but no manual Part II bullet references it — such a spec
# is invisible in every generated count, including the @stable lane the
# daily workflow reads as release signal (issue #985). Whole-repo check, not
# diff-based: main is kept clean, so any new drift fails on the PR that
# introduces it.
- name: Guard spec ↔ doc ↔ QA-CHECKLIST triad
run: npm run check:checklist-coverage
doc-deps-guard:
name: Spec-doc dependency paths
runs-on: ubuntu-latest
timeout-minutes: 6
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: "20"
# The docs this PR touched. Severity turns on this list: a path in a doc the
# PR changed FAILS, a pre-existing one is reported (#980's coverage-first
# trade — one upstream rename must not redden every PR that edits an
# unrelated doc). An empty list is normal and means nothing can fail here.
- name: Collect the docs this PR changed
env:
BASE_REF: ${{ github.base_ref }}
run: |
set -eo pipefail
git fetch origin "$BASE_REF" --quiet
git diff --name-only --diff-filter=d "origin/$BASE_REF...HEAD" -- docs README.md > all-changed.txt
# `grep` exits 1 on no match, which under `pipefail` would abort a PR that
# simply touches no doc — the empty list is a valid, common outcome.
grep -E '\.md$' all-changed.txt > changed-docs.txt || :
echo "changed docs: $(wc -l < changed-docs.txt)"
cat changed-docs.txt
# Trees only, no blobs, no working tree: 520 KB and ~1.6 s measured, against
# ~117 MB to materialise the files. `--mode=check-docs` resolves through
# `git ls-tree`, which is what makes that possible (issue #1298).
- name: Clone the upstream tree
run: |
set -eo pipefail
for attempt in 1 2 3; do
if git clone --filter=blob:none --depth 1 --no-checkout \
https://github.com/langflow-ai/langflow.git langflow-upstream; then
exit 0
fi
echo "::warning::upstream clone attempt $attempt failed; retrying"
rm -rf langflow-upstream
sleep 5
done
echo "::error::could not clone langflow-ai/langflow, so no dependency-path verdict exists for this PR. Not treating that as 'every path resolves'."
exit 1
# Fails only on the changed docs; the repo-wide sweep is emitted as
# ::warning:: so drift that lands upstream between PRs stays visible
# (#1012 — an unevaluated path is unknown, not clean).
- name: Resolve every External-dependencies path against upstream
run: |
set -eo pipefail
node scripts/watch-upstream-areas.mjs \
--mode=check-docs \
--root langflow-upstream \
--ref origin/main \
--changed changed-docs.txt | tee -a "$GITHUB_STEP_SUMMARY"
detect-specs:
name: Detect changed specs
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
specs: ${{ steps.diff.outputs.specs }}
has_specs: ${{ steps.diff.outputs.has_specs }}
needs_models: ${{ steps.diff.outputs.needs_models }}
full_suite: ${{ steps.diff.outputs.full_suite }}
impacted_total: ${{ steps.diff.outputs.impacted_total }}
dropped_count: ${{ steps.diff.outputs.dropped_count }}
# True when no spec imports the diff but this lane runs what changed, so the
# lane runs a fixed canary set instead of skipping (#1159).
canary: ${{ steps.diff.outputs.canary }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: diff
name: Resolve the specs this PR impacts
env:
BASE_REF: ${{ github.base_ref }}
# A helper change can reach half the suite (112 specs for #1052's
# diff), which is a daily-sized run on a PR. Bound it — and NEVER
# silently: the dropped set is printed and summarised (#1012's
# silent-cap rule). The E2E job's own budget is 60 min.
IMPACTED_SPEC_CAP: "20"
run: |
git fetch origin "$BASE_REF" --quiet
# Selection is by IMPORT GRAPH, not by changed-spec glob (#1054).
# Selecting only changed `*.spec.ts` let the highest-reach change the
# repo can receive pass with zero specs executed: PR #1052 changed a
# helper reached by 112 specs and PR #1088 one imported by 135 — both
# reported "skipping". The resolver walks importers TRANSITIVELY, so a
# helper behind a Page Object still selects the specs at the end of
# the chain (a direct-importers-only pass would have found 62 of
# #1052's 112 and read as coverage). A changed spec selects itself, so
# the old behaviour is a subset of this one — including
# `tests/collect-models.spec.ts`, the file directly under `tests/`
# that the `:(glob)` pathspec existed to catch (#1016/#1015/#1007).
git diff --name-only --diff-filter=d "origin/$BASE_REF"...HEAD > /tmp/changed.txt
echo "Changed files:"; sed 's/^/ /' /tmp/changed.txt
node scripts/impacted-specs-by-import.mjs --stdin --format=json --cap "$IMPACTED_SPEC_CAP" \
< /tmp/changed.txt > /tmp/impacted.json || { echo "::error::impacted-spec resolution failed"; exit 1; }
SPECS=$(jq -r '.selected | join(" ")' /tmp/impacted.json)
TOTAL=$(jq -r '.specs | length' /tmp/impacted.json)
DROPPED=$(jq -r '.dropped | length' /tmp/impacted.json)
FULL_SUITE=$(jq -r '.fullSuite' /tmp/impacted.json)
DIRECT=$(jq -r '.direct | length' /tmp/impacted.json)
# `.stableSelected` is deliberately NOT read here. It is scoped to `selected`
# (post-cap), so pairing it with `$TOTAL` (pre-cap) misreported it on every
# capped run, and the exclusion below can shorten the set again. The `@stable`
# tally now comes from the verdict's `.stableRun`, over the surviving list
# (#1226).
# Resolution only. The old tail — "running $TOTAL minus $DROPPED dropped" — was
# the same arithmetic #1226 disproved, and it stayed wrong here after the
# summary was fixed: on a canary it logged "running 0 minus 0" while three
# specs ran. What runs is logged once, below, after the verdict settles it.
echo "Impacted specs resolved: $TOTAL ($DIRECT direct, $((TOTAL - DIRECT)) transitive); $DROPPED dropped by the cap"
# A CI-ONLY diff imports into no spec, so the resolver above honestly
# returns zero and this lane used to skip — proving the change PARSES,
# never that it RUNS (#1159). PR #1157 rewired four workflows onto a new
# composite action with every check green and the E2E lane `skipping`;
# the action had executed nowhere at merge time, and a bad `uses:` path
# would have surfaced first as the next daily failing in a step unrelated
# to any spec.
#
# `ci-change-coverage.mjs` answers the other half: does the PR LANE run
# what changed? It derives that from the YAML (a workflow's `scripts/x`
# refs and its `uses: ./.github/actions/y`, plus each action's own refs),
# so a new action wired in here is covered the day it lands.
# canary → the PR lane's own wiring changed: run a 3-spec set so the
# lane boots Langflow and walks pre-flight → health gate →
# Playwright for real
# dispatch → the surface belongs to another lane; name the workflows to
# dispatch instead of implying coverage
CANARY=false
# Empty unless the classifier ran, so the renderer is only handed a file that
# exists — `${CI_COVERAGE:+…}` then omits the flag entirely rather than passing
# a path to nothing.
CI_COVERAGE=""
if [ "$TOTAL" -eq 0 ]; then
node scripts/ci-change-coverage.mjs --stdin --format=json < /tmp/changed.txt > /tmp/ci-coverage.json \
|| { echo "::error::CI-change classification failed — treating as undecidable rather than as 'no CI change'."; exit 1; }
CI_COVERAGE=/tmp/ci-coverage.json
VERDICT=$(jq -r .verdict /tmp/ci-coverage.json)
jq -r '.reasons[] | " " + .' /tmp/ci-coverage.json
case "$VERDICT" in
canary)
CANARY=true
SPECS=$(jq -r '.canarySpecs | join(" ")' /tmp/ci-coverage.json)
echo "::warning::CI-only change to a surface THIS lane runs ($(jq -r '.ciFiles | join(", ")' /tmp/ci-coverage.json)). No spec imports it, so the lane runs the canary instead of skipping (#1159)."
;;
dispatch)
echo "::warning::CI-only change to $(jq -r '.ciFiles | join(", ")' /tmp/ci-coverage.json), which THIS lane does not run — nothing here proves it works. Dispatch $(jq -r '.dispatchWorkflows | join(", ")' /tmp/ci-coverage.json) on this branch before merging (#1159)."
;;
esac
fi
echo "Running: ${SPECS:-<none>}"
# The SUMMARY is not written here. It is rendered in one pass at the end of
# this step by `scripts/render-impacted-summary.mjs`, because the run count is
# only final after the provider verdict below and because everything that
# qualifies it must print AFTER it, not above it (#1226). The `::warning::`
# annotations stay inline: they are log-level, unaffected by ordering, and
# they are what keeps a shortened run from reading as a clean one even if the
# step dies before it can render (#1012).
if [ "$FULL_SUITE" = "true" ]; then
echo "::warning::This PR changes a suite-wide surface (fixtures / playwright.config / global hooks): ALL $TOTAL specs are impacted. PR CI runs a bounded subset — dispatch manual.yml on this branch for the full suite before merging."
fi
if [ "$DROPPED" -gt 0 ]; then
echo "::warning::Capped at $IMPACTED_SPEC_CAP of $TOTAL impacted specs — $DROPPED not run in this PR (listed in the step log)."
fi
# `has_specs` and `specs` are emitted at the END of this step, after the
# provider-coverage verdict below may have removed specs from the run
# list. Emitting them here as well would write the key twice and leave
# which value wins up to the runner (#1216).
{
echo "full_suite=$FULL_SUITE"
echo "impacted_total=$TOTAL"
echo "dropped_count=$DROPPED"
} >> "$GITHUB_OUTPUT"
# Decide (a) whether the impacted specs need the Collect models sweep and
# (b) whether any of them would otherwise run WITHOUT a provider they
# need. The second question used to resolve silently to "run it anyway",
# which is how PR #1152 — a one-helper change — produced a red in
# `agent-component-regression.spec.ts` (`value-dropdown-model_model` never
# renders with no provider configured) that had nothing to do with its
# diff. That spec is `@stable` and green in the daily, where the sweep runs.
#
# The verdict turns on what the PR is ABOUT, because forcing the sweep for
# every provider-dependent spec would re-couple unrelated PRs to provider
# key health (`Collect models` is a HARD gate here) — the #915/#910/#911
# cost this gate exists to avoid:
# - provider-dependent AND changed directly ⇒ sweep, and run it;
# - provider-dependent only transitively ⇒ EXCLUDE it, and say so.
# A spec that CONSUMES the sweep's output still forces it either way; that
# asymmetry is deliberate and argued in the script's header.
#
# A canary still forces the sweep (#1159), for the health gate below.
# Logic and wording live in the script so `npm run test:scripts` covers
# them; a verdict it cannot produce exits 2 and fails this step rather
# than degrading to "LLM-free" (#1012).
CANARY_FLAG=""
if [ "$CANARY" = "true" ]; then CANARY_FLAG="--canary"; fi
node scripts/provider-dependent-specs.mjs --stdin --format=json \
--changed-file=/tmp/changed.txt $CANARY_FLAG \
< /tmp/impacted.json > /tmp/provider.json \
|| {
echo "::error::provider-coverage verdict failed"
# Render what CAN still be established (resolution, suite-wide, the cap)
# and name the gap, so an aborted verdict leaves an explained summary
# instead of an empty one. `|| true`: this path is already failing, and a
# renderer error must not replace the real cause (#1226).
rm -f /tmp/provider.json
node scripts/render-impacted-summary.mjs --impacted=/tmp/impacted.json \
--specs="$SPECS" --cap="$IMPACTED_SPEC_CAP" $CANARY_FLAG \
>> "$GITHUB_STEP_SUMMARY" || true
exit 1
}
NEEDS_MODELS=$(jq -r '.needsModels' /tmp/provider.json)
# Re-derive the run list from the verdict, so an excluded spec cannot reach
# Playwright — but NEVER on a canary run. A canary's specs come from
# `ci-change-coverage.mjs`, not from the import graph, so `impacted.selected`
# is empty there and overwriting `$SPECS` with the verdict's (empty) run list
# would blank it, trip `has_specs=false`, and skip the whole E2E job — taking
# `Collect models` and the health gate with it, since both live in that job.
# That is precisely the "green check, nothing ran" hole #1159 exists to close.
if [ "$CANARY" != "true" ]; then
SPECS=$(jq -r '.run | join(" ")' /tmp/provider.json)
fi
# Log-level annotations only; the summary bullets for these are the renderer's
# job. Kept inline because a `::warning::` is what still tells a reviewer the
# run was shortened even if this step dies before rendering (#1012).
if [ "$(jq -r '.excluded | length' /tmp/provider.json)" -gt 0 ]; then
echo "::warning::$(jq -r '.warning' /tmp/provider.json)"
fi
if [ "$(jq -r '.forcedToAvoidEmptyRun' /tmp/provider.json)" = "true" ]; then
echo "::warning::Every impacted spec needs a provider, so the sweep runs rather than leaving this lane with nothing to execute — decoupling from key health is not worth ALL of a PR's coverage (#1226)."
fi
echo "Needs provider models: $NEEDS_MODELS"
echo "Running after the provider verdict: ${SPECS:-<none>}"
# The whole summary, rendered once, from the run list this step is about to
# export (#1226). Nothing about WHAT it says is decided here: the counts, the
# order, and every caveat live in `render-impacted-summary.mjs`, where they are
# asserted on OUTPUT by `npm run test:scripts`. Three wrong figures shipped
# from this block while it was inline shell, each one guarded only by a regex
# over this file — and each of those regexes was then shown to miss its own
# mutation. Same reason the verdict above is a script.
node scripts/render-impacted-summary.mjs \
--impacted=/tmp/impacted.json --provider=/tmp/provider.json \
--specs="$SPECS" --cap="$IMPACTED_SPEC_CAP" $CANARY_FLAG \
${CI_COVERAGE:+--ci-coverage=$CI_COVERAGE} \
>> "$GITHUB_STEP_SUMMARY" \
|| { echo "::error::could not render the run summary"; exit 1; }
# The run list is whatever survived the verdict — emitted here and only
# here. An empty list skips the E2E job through `has_specs`, which is the
# only safe outcome: `npx playwright test` with no paths runs the whole
# suite. The ::warning:: above is what keeps that skip from reading as a
# pass (#1012).
HAS_SPECS=false
if [ -n "$SPECS" ]; then HAS_SPECS=true; fi
{
echo "has_specs=$HAS_SPECS"
echo "specs=$SPECS"
echo "needs_models=$NEEDS_MODELS"
echo "canary=$CANARY"
} >> "$GITHUB_OUTPUT"
e2e:
name: Run impacted E2E specs
needs: detect-specs
if: needs.detect-specs.outputs.has_specs == 'true' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 60
services:
langflow:
image: langflowai/langflow-nightly: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, and the
# impacted-specs preflight then times out on /api/v1/version after
# collect-models succeeds (#922, blocked #867). The launch scripts
# already default LANGFLOW_WORKERS=1 for this reason (#773) — the
# service container was the one place missing it.
LANGFLOW_WORKERS: "1"
# Cap how long ONE wedge can cost (#1048). Default is 300 s
# (`worker_timeout` in lfx runtime settings → gunicorn's `timeout`).
# LangflowUvicornWorker is ASYNC, so this is a heartbeat watchdog on the
# event loop, not a request deadline — build duration cannot trip it, and
# Langflow's published docs describe it wrongly. Mechanism, the docs
# caveat, and the startup measurement: see daily-stable.yml.
#
# This job is where the cost was measured. Run 30410211167 (PR #1042)
# burned two full 300 s outages: the post-collect-models wedge the health
# gate below had to wait out (227 s, cleared only by gunicorn's kill),
# and a second block starting within seconds of a failing Google call.
# Four worker kill/restart cycles in 26 min, and the collateral landed on
# specs that make no LLM call at all (general-bugs-shard-3909 timing out
# on `add-project-button`). A wedged worker never self-heals, so waiting
# 300 s buys nothing; at 120 the kill lands 60-120 s after the stall
# instead of 150-300 s.
#
# This BOUNDS the damage, it does not prevent it: a spec whose API call
# has a 20 s timeout still dies inside that window. Eliminating
# collateral needs the provider-heavy specs not to share the backend with
# unrelated ones (serialization / low-concurrency lane) — see #1048.
# Rollback is this one value.
LANGFLOW_WORKER_TIMEOUT: "120"
# Nightly image defaults LANGFLOW_ALLOW_CUSTOM_COMPONENTS=false
# (custom-component creation disabled → sidebar button hidden, API
# 403). Enable it so the custom-component specs exercise the feature.
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"
# Tracing is OFF by default (cuts startup time / backend noise for the
# majority of specs). It is turned ON for two independent reasons, and
# the second one is why this is no longer a single condition (#1300):
#
# 1. The observability/traces specs need the SUT to emit traces +
# spans, and without it their setup times out.
# 2. Langflow's traces are the ONLY place this lane's token spend can
# be read (the recorder below polls /api/v1/monitor/traces, #1197),
# and a flow's trace 404s the moment the flow is deleted.
#
# Reason 2 was missing, and it made #1210's recorder on this lane
# structurally unable to see anything: EVERY run that produced a token
# artifact after #1210 merged recorded `0 trace(s)` — all 35 of them,
# 30855127426 through 31021593309, each carrying a `token-attrib.jsonl`
# and no `token-probes.jsonl` — because tracing was off on every one.
# Run 31018914069 is the clean measurement: 19 `llm-agents` specs plus
# collect-models, a provider configured (`needs_models: true`,
# `Collect models` ran), 59 passed / 29 skipped, and an artifact
# carrying 139 attribution COST records and not one trace. The
# summarizer's own honest-zero message names this flag first among the
# candidate causes, and it was right.
#
# `needs_models` is the lane's own verdict for "this run will execute at
# least one provider-dependent spec" (scripts/provider-dependent-specs.mjs,
# #1216) — the closest thing the lane has to "this run has spend to
# measure", and not the same statement. Two known gaps, neither worth a
# second mechanism: `@playground` is deliberately not a provider tag
# there, and the provider keys are in the Playwright step's env on every
# run, so a spec that installs its own credential and pins a model would
# spend without setting the verdict. Gating on it rather than switching
# tracing on unconditionally keeps the cheap path for the import-graph
# majority of PRs, while the daily and manual, which measure every run,
# keep tracing on unconditionally (daily-stable.yml has been hardcoded
# `"false"` since #459, before the token monitor existed — the previous
# comment here misattributed that to #1197).
#
# What enabling it COSTS. The "cuts startup time / backend noise"
# rationale above is what is being traded away, so be exact about what
# is and is not known.
#
# NOT measured: the startup time, and the backend noise. Nothing in
# #1300 measures either, and this comment does not claim to.
#
# Measured: daily-stable.yml has run with tracing on unconditionally
# since #459 (2026-06-30), and the setting has never been touched since
# (`git log -S`/`-G`, one commit), across 65 runs of the full `@stable`
# sweep — 26 scheduled, 39 dispatched, 2 of them cancelled. So an LLM
# run on this lane now boots in a configuration with ~5 weeks and 26
# scheduled runs behind it, not a novel one. That is an argument from
# precedent. It is weaker than a measurement, and it is what exists.
#
# One attribution NOT to make, because an earlier version of this
# comment made it and this file refutes it 60 lines above: the wedge is
# not wholly a `Collect models` problem. CLAUDE.md does attribute
# #922/#927 to that sweep, but the LANGFLOW_WORKER_TIMEOUT note above
# records run 30410211167 burning TWO 300 s outages — the
# post-collect-models wedge, AND "a second block starting within seconds
# of a failing Google call", i.e. during the spec run itself. That
# second class is exactly the class of run this change newly traces. It
# is bounded by the 120 s worker timeout, not eliminated, and tracing
# has not been shown to affect it either way.
#
# One class does NOT keep the cheap path, and it is the one to know
# about: decideProviderCoverage() sets `needs_models=true` for a CANARY
# run (a CI-only diff, three LLM-free specs), so those now boot with
# tracing on and record another honest zero. That follows from reusing
# the lane's own verdict instead of inventing a second one, and the
# cost is a traced boot on a class of PR that is already paying for a
# full Langflow + `Collect models` sweep.
LANGFLOW_DEACTIVATE_TRACING: ${{ (contains(needs.detect-specs.outputs.specs, 'observability-monitoring') || needs.detect-specs.outputs.needs_models == 'true') && 'false' || 'true' }}
# Let Langflow reach the sibling go-httpbin service below (#1128). Its
# SSRF layer blocks private addresses unless pre-authorized, and the
# API Request component's validators.url() rejects a single-label host
# — so ECHO_BASE_URL is a raw container IP and the RFC-1918 ranges are
# authorized by CIDR rather than by a fixed address the Docker network
# assigns per run. Same set as daily-stable.yml (#462).
LANGFLOW_SSRF_ALLOWED_HOSTS: "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
# Self-hosted echo endpoint for the API Request and agent-fetch-tool specs.
# Without it this lane calls public httpbin.org / postman-echo.com and reds
# on their outages: PR #1133 lost three specs to an httpbin 504 while its
# own diff touched none of them, and the log read like a product failure
# (#1128; earlier recurrences #383/#407/#462/#639).
#
# This job runs on the RUNNER HOST, not in a container, so the service
# alias is not resolvable here — `resolve-echo-endpoint` reads the
# container IP with `docker inspect` instead, probes the published port,
# and hands Langflow the IP. Tag has NO `v` prefix (#639).
# No healthcheck: the scratch-based image ships no shell or curl, so the
# action's poll is the healthcheck.
go-httpbin:
image: ghcr.io/mccutchen/go-httpbin:2.23.1
ports:
- 8080:8080
# 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.
env:
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 THIS file from a spec change,
# which flips this 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 run. 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
- uses: actions/setup-node@v7
with:
node-version: "20"
cache: "npm"
- run: npm ci
- name: Install Playwright browsers
uses: ./.github/actions/setup-playwright
# Before ANY spec runs, including collect-models: point the echo-dependent
# specs at the sibling go-httpbin service instead of the public internet
# (#1128). `mode: fail` is deliberate and is the one place this lane differs
# from the daily: a PR is the lane a human is waiting on, so a silent
# fallback to public httpbin.org — which is what reded PR #1133 and read as
# a product failure — is worse than a named infra failure it can be
# re-dispatched from. The daily keeps `warn`, where a day of coverage
# outweighs strictness.
- name: Resolve echo endpoint
uses: ./.github/actions/resolve-echo-endpoint
with:
mode: fail
in_container: "false"
# Collect provider/model data BEFORE the impacted specs, mirroring
# daily-stable.yml. Without this, `models.json` is absent (it is
# git-ignored) and an agent/LLM spec's `resolveTestTargets` falls back to
# picking a model straight from the live agent dropdown by name — without
# validating that the key can access it. On some nightly builds that
# resolves to a model the CI OPENAI_API_KEY's project cannot use (e.g.
# `gpt-4.1-nano`), so the Agent build 403s and the spec dies on setup,
# unrelated to the PR (#873). `collect-models` probes each key with a real
# ~1-token completion and writes only ACCESSIBLE models, so agent specs
# resolve a usable model — matching what the daily validates (no
# divergence). Same provider keys as daily-stable.yml.
# Skipped when no impacted spec needs provider model data (see detect-specs
# → needs_models). Keeps a provider billing/quota outage from reddening a
# PR whose specs never use that provider (#915/#910/#911 class).
- name: Collect models
if: needs.detect-specs.outputs.needs_models == 'true'
# Fatal on a normal LLM PR (a spec that needs models must not run without
# them), NON-fatal on a canary run: there the sweep exists only so the
# health gate below has something to gate, and nothing in the canary
# consumes a provider key — so a drained key must not block a CI-only PR
# (#1159). Same trade the daily makes for the same reason (#980).
continue-on-error: ${{ needs.detect-specs.outputs.canary == 'true' }}
env:
CI: "true"
# 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
# (chicken-and-egg). Matches daily-stable.yml's Collect models step.
PREFLIGHT_SKIP_CREDENTIALS: "1"
# NO RETRIES for this step (#1011, ported here by #1019). CI's default
# is 2, so a FAILING collect-models runs three full attempts, each
# re-importing every key and re-walking the Model Providers UI against
# the single backend this job shares with the specs. On the daily that
# multiplier turned a ~49 s step into 7-12 min of sustained load and
# wedged the gunicorn worker.
#
# The trade is NOT the daily's, and the difference is worth stating.
# There the step is `continue-on-error: true` (#980): its result is
# purely diagnostic, so dropping the retries costs nothing and spares
# the shard that still has to run. Here the step is a HARD gate — a red
# sweep fails the job outright, the specs never run, and the health gate
# below is skipped for want of `always()`. So the cap protects no
# downstream step; what it buys is a red in ~1 min instead of three
# sweeps' worth of load and runner minutes. What it COSTS is the retry
# that could have rescued a transient sweep failure (a provider 503, a
# UI timing flake) — a real, if narrow, loss of PR-check reliability.
# Accepted deliberately: a sweep that fails for a substantive reason
# (drained key, component missing from the image — #1039) fails all
# three attempts too. Revisit if PRs start going red here on flakes a
# retry would have absorbed.
#
# Note the cap alone would NOT have prevented the wedges of 2026-07-28
# (three occurrences, all after a collect-models that PASSED on its
# first attempt in ~1 min). One successful sweep is enough to wedge the
# backend; the health gate below is what covers that case.
PLAYWRIGHT_RETRIES: "0"
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 }}
run: npx playwright test tests/collect-models.spec.ts --reporter=line
# Health gate between the two steps that share the Langflow container —
# ported from daily-stable.yml (#1011) by #1019. 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
# whole job and reports itself as a preflight timeout on /api/v1/version with
# no mention of the cause.
#
# On the daily this gate was documented as "attribution, with recovery as
# upside" because no run had ever shown the backend coming back. Run
# 30405897916 (job 90432861222) measured it — a pr-validation run that had
# NO gate yet, so what stood in for one was globalSetup's own 120 s poll.
# From the service container's log:
#
# 23:01:09 Collect models → 1 passed (57.2s)
# 23:01:18 globalSetup starts polling /api/v1/version
# 23:03:18 globalSetup gives up (HEALTH_TIMEOUT_MS = 120000) → job RED
# 23:04:39 critical WORKER TIMEOUT (pid:36) [gunicorn.error]
# 23:04:40 error Worker (pid:36) was sent SIGKILL!
# 23:04:51 replacement worker finishes startup ("Initializing agentic
# global variables…"), serves /auto_login at 23:04:52
# ← 214 s after polling began
#
# Read that last line for exactly what it is: recovery inferred from the
# replacement worker's startup log, not from an observed 200 on
# /api/v1/version — by then nothing was polling, the job had already died.
# It is enough to establish that the backend came back, and that this step's
# budget would have found it where globalSetup's 120 s missed by 94 s. So
# here the gate is a RECOVERY mechanism, not only a diagnostic one — on one
# measurement. The same log explains why the service-level --health-cmd
# cannot cover this: gunicorn itself only noticed ~3.5 min in, and the
# container reads healthy that whole time.
#
# Shared implementation since #1045 (`.github/actions/wait-for-backend`) —
# the deadline, the heartbeat and the curl-exit-code decoding this copy
# pioneered now apply to the daily and manual lanes too, and weekly-stable
# finally has a gate at all. Runs only when Collect models ran: with no sweep
# there is no wedge to wait out, an unrelated startup failure is
# globalSetup's to report, and an LLM-free PR must not be billed for the wait.
- name: Wait for the backend to recover from the collect-models load
if: needs.detect-specs.outputs.needs_models == 'true'
uses: ./.github/actions/wait-for-backend
with:
# Deadline comes from the action's default (420 s) — this lane is where
# that number was argued for and it is now every lane's.
next_step_label: "the impacted-specs run"
attribution: "NOT a failure of the specs this PR touches"
# No collect_models_outcome: unlike the daily and manual lanes, the step
# here is a HARD gate (not `continue-on-error`), so a failed sweep has
# already reddened the job and there is no surviving run to warn about.
# Pin this lane to ONE provider's settled model (#1169). resolveTestTargets()
# parametrizes over one model per active provider, so every impacted agent
# spec runs an openai AND an anthropic AND a google variant here. That is
# daily-stable.yml's job — it is the lane that owes multi-provider coverage.
# This lane answers "does this PR break the specs it touches", which one
# provider settles, and the difference is not cosmetic: measured 2026-07-31,
# this workflow ran 141 times in 3.5 days, each model-needing run paying an
# anthropic variant on claude-sonnet-5 ($3/$15 per MTok) for assertions
# gpt-4o-mini ($0.15/$0.60) satisfies identically — 20-25x per token, with no
# prompt caching on the anthropic side. Both the CI secret and the local key
# drained inside that window (Anthropic credit is account-scoped, so they
# share one balance).
#
# Why a script (see its header for the full argument): MODEL_TEST_PROVIDER
# alone does not narrow the run, it runs the provider's ENTIRE catalog (the
# dedup branch is skipped) — so the two variables must be emitted as a pair,
# which this does. And the model must be the one collect-models settled on:
# a hardcoded id skips silently the day the CI project loses access, leaving
# a green PR that tested nothing (#570/#1012).
#
# Declines to pin — with a ::warning:: — when the provider is not active, so
# a drained openai key costs a costlier multi-provider run rather than zero
# coverage. Gated on needs_models for the same reason as the two steps above:
# with no sweep there is no settled model to read.
- name: Pin the lane to a single provider's settled model
if: needs.detect-specs.outputs.needs_models == 'true'
run: node scripts/select-pr-model-target.mjs --provider openai
# In-run token consumption recorder, extended to this lane (#1183 — measure
# the suite's real LLM spend per lane/provider). Same mechanism as
# daily-stable.yml (#1197): poll /api/v1/monitor/traces and price what each
# trace spent, because deleting a flow 404s its trace and this suite deletes
# every flow it creates, so the only place to read the data is DURING the run.
#
# Placed here — after Collect models / the health gate / the model pin,
# right before the actual Playwright step — so it only ever runs where
# specs actually run: the job itself is already gated on
# `needs.detect-specs.outputs.has_specs == 'true'`, and this step carries
# no separate condition of its own, same as "Run impacted specs" below (an
# earlier hard failure in this job skips both identically).
#
# MEASUREMENT-ONLY on this lane: --summarize (below, after the run) still
# renders a step-summary table, but TOKENS_SUPPRESS_HISTORY keeps it from
# writing reports/token-history.jsonl — that file is the daily's series,
# one line per FULL @stable sweep, and this lane's scope is whatever
# subset of specs the import graph selected for THIS PR (capped at
# IMPACTED_SPEC_CAP, frequently zero LLM specs at all). Mixing the two
# would corrupt the trend and the anomaly baseline the daily itself relies
# on (see the summarizer's own comment on the knob).
#
# DIAGNOSTIC ONLY: continue-on-error, and it never gates the run.
- name: Start the token consumption recorder
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.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.
# Below the job's timeout-minutes: 60 so this is a real backstop, not
# dead configuration reached only after the runner already killed the
# job. Same reasoning as the daily's TOKENS_MAX_SECONDS.
TOKENS_MAX_SECONDS: "3300"
- name: Run impacted specs
env:
CI: "true"
# When "Collect models" is skipped (needs_models == false) the provider
# keys below are still in the environment but were never imported into
# Langflow as global variables, so globalSetup's credential pre-flight
# (#884) hard-fails in CI and the job dies before running a single
# spec — every LLM-free spec PR was guaranteed red after #953 added the
# skip. Nothing in an LLM-free run consumes those credentials, so the
# check has nothing to protect there: skip it exactly when the import
# step was skipped, and keep it enforced for LLM runs.
#
# A canary run is the third case (#1159): the sweep DID run, but only to
# exercise the health gate, and it is allowed to fail. Enforcing the
# credential pre-flight there would let a drained provider key kill a
# CI-only PR through the back door — the canary specs make no LLM call at
# all, so there is nothing for the check to protect.
PREFLIGHT_SKIP_CREDENTIALS: ${{ needs.detect-specs.outputs.needs_models == 'true' && needs.detect-specs.outputs.canary != 'true' && '0' || '1' }}
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 }}
SPECS: ${{ needs.detect-specs.outputs.specs }}
# Turns the cleanup sidecar on for this lane (#1183), same as
# daily-stable.yml: with this unset (local runs) the helper makes no
# request and writes no file.
TOKENS_ATTRIB: token-attrib.jsonl
run: |
echo "Running specs: $SPECS"
# shellcheck disable=SC2086
npx playwright test $SPECS --reporter=github
# Destructive lane (#1010). `@destructive` tests wipe account-wide state, so
# playwright.config.ts keeps them out of the run above and pins this lane to
# workers=1. Scoped to the same impacted $SPECS, so it is a no-op (via
# --pass-with-no-tests) unless a destructive spec is actually impacted.
# --reporter=github replaces the reporter list, so no HTML is written here and
# the main run's playwright-report/ stays intact.
- name: Run destructive lane
if: always()
env:
CI: "true"
PW_DESTRUCTIVE: "1"
PREFLIGHT_SKIP_CREDENTIALS: ${{ needs.detect-specs.outputs.needs_models == 'true' && '0' || '1' }}
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 }}
SPECS: ${{ needs.detect-specs.outputs.specs }}
run: |
# shellcheck disable=SC2086
npx playwright test $SPECS --grep "@destructive" --pass-with-no-tests \
--reporter=github --output=test-results-destructive
# Stop the recorder, price what it saw, and upload the raw JSONL — mirrors
# daily-stable.yml's shard-level "Stop and collect" + merge-job "Summarize"
# steps, collapsed into one job since this lane has no shards to merge.
# always(): the recorder's data is most useful precisely when the run went
# red, and continue-on-error on every step here means a defect in the
# reporting itself can never touch the specs' own pass/fail.
- name: Stop and collect token consumption
if: always()
continue-on-error: true
shell: bash
run: |
# SIGTERM is the recorder's normal exit path: it stops after the
# current probe.
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 — same
# reasoning and margin as the daily's own copy of this step.
sleep 10
mkdir -p tokens
cp token-probes.jsonl tokens/ 2>/dev/null || true
cp token-attrib.jsonl tokens/ 2>/dev/null || true
echo "--- token recorder stdout (tail) ---"
tail -n 5 /tmp/tokens.log 2>/dev/null || true
- name: Summarize token consumption
if: always()
continue-on-error: true
run: node scripts/watch-tokens.mjs --summarize
env:
TOKENS_DIR: tokens
TOKENS_PRICES: scripts/lib/model-prices.json
WORKFLOW: pr-validation
LANGFLOW_IMAGE: "langflowai/langflow-nightly:latest"
# Measurement-only lane (#1183): never add a line to
# reports/token-history.jsonl. See "Start the token consumption
# recorder" above and the summarizer's own comment on this knob for
# why a capped, per-PR subset is not comparable to the daily's series.
# Suppressed openly, not silently: the summarizer states this in both
# its log line and the step summary itself (#1012's rule).
TOKENS_SUPPRESS_HISTORY: "1"
- name: Upload token consumption
uses: actions/upload-artifact@v7
if: always()
continue-on-error: true
with:
name: tokens-pr-${{ github.run_id }}
path: tokens/
if-no-files-found: ignore
- name: Upload Playwright report
uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report-pr-${{ github.run_id }}
path: playwright-report/
retention-days: 14
- name: Upload test results on failure
uses: actions/upload-artifact@v7
if: failure()
with:
name: test-results-pr-${{ github.run_id }}
path: test-results/
retention-days: 7