-
Notifications
You must be signed in to change notification settings - Fork 0
2132 lines (2037 loc) · 141 KB
/
Copy pathci.yml
File metadata and controls
2132 lines (2037 loc) · 141 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
schedule:
# Nightly (03:17 UTC — off the top-of-hour rush) run of the HEAVY POST-MERGE legs: the load
# smokes (load-test, load-test-sqlserver x2 majors), the server-DB suites (sqlserver-store x2
# majors, postgres-store), windows-service-smoke (x2 SKUs, 2x-billed) and docker-smoke. These
# all used to run on EVERY push to main (~16 auto-merges/day re-validating content whose PR run
# had just passed); the nightly cadence + the PR path-gates (`changes`) keep the same coverage
# at a fraction of the runs. `gh workflow run ci.yml --ref main` (workflow_dispatch) still runs
# EVERYTHING on demand — use it to re-validate a specific merge immediately.
# IMPORTANT: the cron must still NOT drag the FUNCTIONAL matrix along. `test` and `ide` gate
# their full-coverage arm as `push || workflow_dispatch` — deliberately EXCLUDING `schedule` —
# so the nightly never re-executes them (they already ran on the merge itself; the original
# `!= 'pull_request'` form would have swept them into the cron). The nightly legs' `if:` is
# `schedule || workflow_dispatch` (+ a PR path-gate arm where `changes` provides one).
- cron: "17 3 * * *"
concurrency:
# Per-ref group so a new push/PR-update supersedes its own in-flight run. The nightly `schedule` run
# gets a SEPARATE group (…-nightly): without this it would share `ci-refs/heads/main` with push-to-main
# and a routine daytime push would cancel-in-progress the in-flight nightly LOAD run — silently killing
# the very long-running coverage the cron exists to provide. Scheduled and push runs no longer collide.
group: ci-${{ github.ref }}${{ github.event_name == 'schedule' && '-nightly' || '' }}
cancel-in-progress: true
# Least privilege: every job here only reads the repo and runs tests/builds (no gh writes, no git push,
# no secrets), so a read-only default token is sufficient. A job needing more declares its own block.
permissions:
contents: read
jobs:
# Lint + type-check + unit tests. The project supports a single Python — 3.14 — so every leg runs it.
# Linux carries the cheap breadth (1x minutes); Windows Server 2022 + 2025 is the primary deployment
# target, so 3.14 is also exercised on both Server SKUs even though Windows bills at 2x — testing the
# version we ship on the OS we ship it on is worth the minutes.
test:
name: test (${{ matrix.os }}, py${{ matrix.python-version }})
# `needs: changes` for the docs-only short-circuit ONLY — this REQUIRED job still RUNS on every PR
# and ALWAYS reports its context. On a docs-only PR `changes.outputs.code == 'false'` and the
# expensive steps (install/lint/type/test) are skipped, so the leg goes green in seconds; the
# required `test (…, py3.14)` context stays present + green. (changes never fails, so this needs
# edge can't wedge the required context.)
needs: changes
# Every leg runs on GitHub-HOSTED runners now — the self-hosted NucBox Windows runners are RETIRED.
# This repo (MEFORORG) runs the full ubuntu + windows-2022 + windows-2025 matrix, all FREE (hosted
# minutes are free on a public repo); a FORK building its own pushes gets the UBUNTU leg only, to
# keep a contributor's personal CI cheap. Hosted-everywhere is also safer: a self-hosted runner must
# never be reachable by a public repo (fork PR = RCE on the owner's LAN). `matrix.hosted` is the
# runner label set; the per-repo matrix is built in `changes`.
runs-on: ${{ matrix.hosted }}
# Wall-clock backstop: bound each leg so a hung test fails in minutes, not the 6h default. Three
# nested watchdogs catch a hang at increasing scope (#55): the pytest-timeout per-test cap (matrix
# `pytest_timeout`, 60s ubuntu / 120s Windows -- the step passes `--timeout=`, which overrides the
# pyproject addopts value) + the faulthandler belt fire FIRST and name the stuck frame; the pytest STEP
# has its own `timeout-minutes` (matrix `step_timeout`) so a process-level deadlock below pytest
# fails the step fast; this job cap is the outermost belt if even that is somehow out-raced.
timeout-minutes: ${{ matrix.job_timeout }}
strategy:
fail-fast: false
# Per-repo matrix from the `changes` job: the full ubuntu+windows matrix here, ubuntu-only on a
# fork's own pushes. Each leg carries os / python-version / hosted (the runner labels, an array) +
# the timeout knobs. NB: a fork *PR* against this repo still runs in THIS repo's context, so it
# gets the full matrix — the required `test (windows-2022/2025, py3.14)` contexts are always
# present on a PR here, and a required-but-absent context (which blocks a PR forever) can't occur.
matrix: ${{ fromJSON(needs.changes.outputs.matrix) }}
defaults:
run:
shell: bash # available on all runners (Git Bash on Windows) -> one set of commands
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}
# No `cache: pip`: installs now go through uv (the "Set up uv" step below), which keeps its
# OWN wheel/download cache — a pip cache here would be a second, redundant cache competing
# for the repo's 10 GB cache budget. uv's cache is keyed on pyproject.toml + requirements.lock.
# Everything below is gated on `code` (docs-only short-circuit) OR a push-to-main / on-demand run
# (full coverage). On a docs-only PR these steps no-op and the REQUIRED leg reports green in seconds.
# The job itself (and the Python/cache setup above) still runs, so the required context is always
# present. NB: the gate is duplicated per-step (a job-level `if` would SKIP the whole job and remove
# the required context) — keep the condition identical on each. The push/dispatch arm is written
# `push || workflow_dispatch` (NOT `!= 'pull_request'`) so the nightly `schedule` event does NOT
# re-run this full functional matrix — the cron is reserved for the long load legs only.
# DELIBERATELY UNGATED — the one step in this job with no `code` condition.
#
# The ledger gate polices the ADR/BACKLOG number space, and an ADR-only PR is by definition
# DOCS-ONLY, so `code == 'false'` and every gated step above is skipped. Gating this one the same
# way would skip it on exactly the pull requests it exists to police. It needs only git + stdlib
# python (no install), so it costs seconds and runs on every PR.
#
# It rides inside the already-REQUIRED `test` leg rather than becoming a new required context: a
# brand-new required check wedges every PR opened before it existed (see the `ci-gate` roll-up).
#
# This is the backstop for `git commit --no-verify` and for a branch cut from a stale main — each
# branch is internally consistent, and the duplicate number only exists once BOTH have merged, so
# only a check against freshly-fetched main can see it.
- name: Ledger gate (ADR / BACKLOG number space)
if: runner.os == 'Linux'
run: |
# Only `origin/main` needs fetching: the gate reads it (`git show origin/main:…`, `ls-tree`) and
# TWO-dot-diffs it against HEAD. No ancestry is resolved, so the shallow checkout is fine and no
# deepening is required -- see the long note in ledger_check.py's tree-access section for why
# three-dot was wrong here, and why its failure was silent.
git fetch --no-tags --depth=200 origin main
python scripts/hooks/ledger_check.py --ci
# ALSO DELIBERATELY UNGATED, for exactly the reason the step above is — and it was NOT, which
# cost `main`.
#
# Two guards police docs/BACKLOG.md. The number-space gate above is ungated because "gating it
# would skip it on exactly the pull requests it exists to police". The STATUS invariant was
# reachable only through pytest, which IS gated on `code == 'true'` — so on a BACKLOG-only PR
# (the exact shape it exists to check) it did not run at all.
#
# On 2026-08-01 that landed a #320 entry whose banner used an emoji the invariant does not
# accept. The PR was docs-only, went green in seconds without compiling the suite, merged, and
# red `main` for every other session until someone else's PR tripped over it. A 0.6s markdown
# parse would have caught it. One hardened guard and one unhardened guard over the same file is
# worse than neither, because the hardened one makes it reasonable to assume BACKLOG changes are
# covered.
#
# No install needed, so the step above's "git + stdlib python, costs seconds" property holds:
# scripts/docs/backlog_status_check.py imports only argparse/re/sys/pathlib and has its own CLI.
# tests/test_backlog_status_check.py imports that SAME module, so there is one implementation and
# the unit tests keep covering its edge cases — this only adds the always-on invocation.
# --min-items is the anti-narrowing floor, and it is the point of this invocation as much as the
# banner check is. The item namespace spans docs/BACKLOG.md AND docs/archive/backlog/: retiring
# items moves them between those files, so every OTHER assertion here — one banner per item, no
# contradictions, no duplicates — is satisfied just as easily by scanning a remnant of the corpus
# as the whole of it. Without a floor, a change that stopped the archive being read would go green.
# Raise the number when the total legitimately grows; it must never be lowered to make CI pass.
# WARNING: THE FLOOR LIVES IN TWO PLACES AND NOTHING COMPARES THEM: here, and `_MIN_TOTAL_ITEMS` in
# tests/test_backlog_status_check.py. Raise BOTH, or the lower one becomes the only floor that
# binds. Found on 2026-08-05 at 277 against a corpus of 300 — 23 items of accumulated slack, in a
# guard whose entire purpose is to notice the corpus shrinking.
- name: Backlog status invariant (ungated — see above)
if: runner.os == 'Linux'
run: python scripts/docs/backlog_status_check.py --min-items 300
# THE SAME ARGUMENT, ONE STEP FURTHER — and the evidence is from 2026-08-04.
#
# Ten `*_doc_drift` / `docs_*` modules exist to police documents, and every one of them is
# reachable ONLY through pytest, which is gated on `code == 'true'` two steps below. So on a
# docs-only PR — the exact shape they exist to check — none of them runs. That is the same defect
# the status invariant above was added to fix, at ten times the surface.
#
# It is not hypothetical. On 2026-08-04 four docs-only PRs merged (#197, #198, #200, #201) and the
# doc guards ran on none of them. Two carried citation errors found only by reading: an ADR named
# ONE inbound citation of `docs/releases/` where there are 23 (12 via `docs/releases/...`, 15 via
# relative `(releases/...`, and neither grep form alone finds them all), and a `docs/SECURITY.md`
# route-table row asserted a refusal `DELETE /me/mfa` does not make.
#
# IT RECURRED ON 2026-08-11, AND THIS STEP WAS ALREADY HERE — the list was simply incomplete.
# `tests/test_dast_claims.py` scans documents for prose reading as though the independence gap
# were closed, but was never added, so a docs-only PR (#322) merged green and RED MAIN on the push
# afterwards. The blind mode is worse than a plain gap because of WHO PAYS: the failure surfaces
# on the next PR that touches code, so it is misattributed to whoever opens it. That happened —
# the next code PR inherited a red its author had no part in.
#
# => THE LESSON IS ABOUT THE LIST, NOT THE GATING. A curated allowlist silently omits; nothing in
# a green run says "a doc guard exists that I did not run." When adding a doc-scanning test
# module anywhere in `tests/`, add it HERE in the same commit, and confirm it needs no extras —
# a module that cannot run on `[dev]` alone would red every docs-only PR, which is the failure
# mode the minimal-install note below exists to avoid.
#
# WHY A SEPARATE MINIMAL INSTALL instead of ungating the install below. Unlike the status
# invariant, these modules import the package (`config.settings`, `config.models`,
# `parsing.binary`, `auth.service`) and one imports `fastapi.routing`, so they cannot run on
# stdlib alone. But they need NO extras — fhir/dicom/x12/xml are irrelevant to a doc scan — so a
# docs-only PR pays a base editable install and ~5s of tests rather than the full extras install.
# The gated steps below are deliberately UNTOUCHED, so a code PR is byte-identical to before.
#
# WARNING: 89 of these tests SKIP here and that is structural, not a gap to fix in this step:
# `tests/test_threat_model_doc_drift.py` asserts against `docs/security/THREAT-MODEL.md`, which is
# vault-only and absent from this tree. ADR 0156 records that class (six `*_doc_drift` modules
# assert against documents `git ls-files docs/security/` shows are not present) and ASVS 15.1.3 is
# open on it. 152 assertions DO run, which is the point; do not read the skips as coverage.
# `[dev]` and not a bare `pip install pytest`, and this is NOT belt-and-braces. pyproject sets
# `asyncio_mode = "auto"` (needs pytest-asyncio) and `addopts = "--timeout=60
# --timeout-method=thread"` (needs pytest-timeout), so a bare pytest ERRORS on an unknown option
# before collecting anything — a step that could never pass, reding every docs-only PR. `[dev]` is
# the canonical "can run this suite" extra and pins both plugins; it is still far lighter than the
# gated install below, which adds console + fhir + dicom + x12 + xml that no doc scan touches.
# `--constraint constraints.lock` for the same DEP-1 reason every other install here carries it.
- name: Install (minimal — for the doc guards on a docs-only PR)
if: runner.os == 'Linux' && needs.changes.outputs.code != 'true' && github.event_name == 'pull_request'
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]" --constraint constraints.lock
- name: Doc guards (ungated — the docs-only blind spot; see above)
if: runner.os == 'Linux' && needs.changes.outputs.code != 'true' && github.event_name == 'pull_request'
run: |
# PRINT WHAT IS SCANNED BEFORE RUNNING IT. A list that silently shrinks is how this class of
# guard goes quiet, and `-rs` names every skip rather than letting one read as a pass.
#
# ONE LIST, USED TWICE. It used to be written out twice — once for the printf and once for
# pytest — which made the "print what you scanned" defence able to LIE: the two copies could
# drift, and then the step prints a module it does not run, or runs one it does not print.
# A defence implemented by duplication defeats itself. The variable is the fix, and the
# cross-check below turns a drift into a hard failure rather than a quiet one.
DOC_GUARDS="tests/test_asvs_file_surface_doc_drift.py tests/test_cloud_phi_hipaa_doc_drift.py
tests/test_crit2_inline_doc_drift.py tests/test_doc_ref_handle.py tests/test_docs_db_grants.py
tests/test_docs_runbooks.py tests/test_docs_security_pathways.py tests/test_security_doc_drift.py
tests/test_security_doc_rate_limits.py tests/test_threat_model_doc_drift.py
tests/test_backlog_status_check.py tests/test_sds_rule_ids_are_stable.py
tests/test_link_resolution.py tests/test_dast_claims.py"
# Every named module must EXIST. A path typo would otherwise make pytest error on an unknown
# file, or — worse under a future -k/--ignore form — silently scan nothing and read as a pass.
for m in $DOC_GUARDS; do
test -f "$m" || { echo "doc-guard list names a missing module: $m"; exit 1; }
done
echo "doc guards, docs-only PR — scanning these modules:"
printf ' %s\n' $DOC_GUARDS
echo " ($(printf '%s\n' $DOC_GUARDS | wc -l) modules)"
pytest -q -rs $DOC_GUARDS
# PySide6's offscreen platform plugin needs a few system libraries even
# headless. Linux-only; Windows runners need no equivalent.
- name: Install Qt offscreen system libraries
if: (needs.changes.outputs.code == 'true' || github.event_name == 'push' || github.event_name == 'workflow_dispatch') && runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libegl1 libgl1 libxkbcommon0 libdbus-1-3
# uv is the installer for every leg (was pip): it resolves + installs the pyproject extras far
# faster than pip, into the same setup-python interpreter via `uv pip install --system`. Pinned
# by the same SHA dependabot-lock-resync.yml already uses. Gated identically to the install
# below (docs-only PRs skip both). enable-cache defaults to 'auto' — a GitHub-hosted cache on
# hosted runners, the persistent on-disk uv cache on a self-hosted runner — keyed on the glob.
#
# EVERY install below passes `--constraint constraints.lock` — the HASHLESS export of uv.lock, kept
# in sync by the DEP-1 gate. Without it, `uv pip install -e ".[extras]"` RE-RESOLVES from
# pyproject's `>=` floors and silently adopts whatever upstream published since: ruff 0.16.0 and
# annotated-types 0.8.0 (dropped `SLOTS`, broke fhir-core) each reddened EVERY open PR on the same
# day — see each cap's rationale in pyproject.toml. The constraint pins every version to the lock
# while each job still installs only ITS OWN extras (so windows-service-smoke's deliberately
# narrow set stays narrow; the docker-smoke job builds from docker/locks/* and installs nothing).
- name: Set up uv
if: needs.changes.outputs.code == 'true' || github.event_name == 'push' || github.event_name == 'workflow_dispatch'
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
cache-dependency-glob: |
pyproject.toml
requirements.lock
- name: Install project (dev + console + fhir + dicom + x12 + xml extras)
if: needs.changes.outputs.code == 'true' || github.event_name == 'push' || github.event_name == 'workflow_dispatch'
run: |
# [fhir] so the FHIR typed-tier tests (incl. the PHI-no-leak invariant, ADR 0022) actually
# run their assertions in CI rather than importorskip-skipping (extra-less local runs skip).
# [dicom] (pydicom + pynetdicom; ADR 0025) does the same for the DICOM codec + C-STORE SCP
# tests, and lets mypy type-check transports/dicom.py + parsing/dicom/ against the real
# (py.typed) libraries rather than treating them as Any.
# [x12] (pyx12) + [xml] (lxml/xmlschema/signxml) likewise so the strict X12 validation (#32)
# and the hardened XML/SOAP codec (#31) tests run their assertions instead of importorskip-skipping.
# [webauthn] (ADR 0068) so the passkey ceremony tests run real verify_* assertions instead of
# importorskip-skipping, and mypy sees the py.typed library rather than Any.
# + the browser ops console as a second editable wheel (Option B) so the moved /ui tests
# (tests/test_webui.py etc. now import messagefoundry_webconsole) run on this full-suite leg.
# It is NOT a published extra yet (would break uv lock), so install it by path.
uv pip install --system --constraint constraints.lock -e ".[dev,harness,fhir,dicom,x12,xml,webauthn]" -e packaging/messagefoundry-webconsole
# ruff (lint + format) and mypy are PLATFORM-INDEPENDENT results — a ruff/format outcome is
# identical on every OS, and mypy's platform-specific branches are covered by running it for
# BOTH platforms on the cheap Linux leg (below). Running them on the 2x-billed Windows legs was
# duplicate cost, so gate them to Linux. The Windows legs keep pytest — the one check whose
# result genuinely differs by OS (real sockets, ProactorEventLoop, Windows service paths).
# `.` — the WHOLE repo, matching the format check below and the pre-commit ruff hook, which has no
# `exclude` and so lints every changed Python file. This was an explicit allow-list of six paths,
# and the mismatch was not academic: harness/ (73 findings), tee/ (12), samples/ and docker/ were
# linted by the hook and by nothing in CI, so touching any file there blocked the commit on errors
# CI could not see and no CI run could ever have reported. Scope now lives in ONE place —
# pyproject's [tool.ruff] extend-exclude — instead of being re-stated, differently, per tool.
# tests/test_lint_scope_parity.py fails if the two drift apart again.
- name: Lint (ruff)
if: (needs.changes.outputs.code == 'true' || github.event_name == 'push' || github.event_name == 'workflow_dispatch') && runner.os == 'Linux'
run: ruff check .
- name: Format check (ruff)
if: (needs.changes.outputs.code == 'true' || github.event_name == 'push' || github.event_name == 'workflow_dispatch') && runner.os == 'Linux'
run: ruff format --check .
# Two mypy passes on Linux so BOTH platforms' typed branches are checked without paying for a
# Windows mypy: the default linux-platform run, plus an explicit --platform win32 run that types
# the sys.platform=='win32' branches (ctypes.windll / DPAPI / service_control) the linux run
# skips. Previously those were typed only by the mypy on the Windows legs. (All win32 code uses
# stdlib ctypes — typeshed's win32-conditional stubs cover it; there are no win32-only deps — so
# this resolves on a Linux runner. Verified: both passes clean on 204 files.)
# `messagefoundry/tray` (the Windows notification-area service-manager, ADR 0113 — now shipped
# inside the wheel) is a win32-only ctypes package, so it is EXCLUDED here and typed on the win32
# pass only: its ungated ctypes.WinDLL method calls are unreachable-and-skipped under the linux
# platform but real under win32. Nothing in the engine imports it (it is a leaf entry point), so
# --exclude cleanly drops it from the linux build.
- name: Type-check (mypy, strict — linux platform)
if: (needs.changes.outputs.code == 'true' || github.event_name == 'push' || github.event_name == 'workflow_dispatch') && runner.os == 'Linux'
run: mypy messagefoundry messagefoundry_webconsole --exclude 'messagefoundry/tray/'
# The win32 pass types messagefoundry/tray (now inside the messagefoundry package, so no separate
# path argument) plus every other sys.platform=='win32' branch.
- name: Type-check (mypy, strict — win32 platform)
if: (needs.changes.outputs.code == 'true' || github.event_name == 'push' || github.event_name == 'workflow_dispatch') && runner.os == 'Linux'
run: mypy --platform win32 messagefoundry
# Step-level watchdog UNDER the job cap (#55): the windows-2022 leg intermittently
# wedges ~25% in (a Windows ProactorEventLoop listener-teardown / socket wait that the shared
# session event loop can't get past), emits no output for ~12 min, then the JOB cap CANCELS it
# — a red X with no stack and no named test. Two belts make that fail FAST and NAMED instead:
# * `--timeout-method=thread` (the ONLY method on Windows — SIGALRM is POSIX-only) dumps ALL
# thread stacks at the per-test cap (matrix `pytest_timeout`, 120s on these Windows legs),
# naming the stuck frame.
# * PYTHONFAULTHANDLER=1 + `-o faulthandler_timeout=` (matrix `fault_timeout`) is the belt that fires even when the
# thread-timer CANNOT interrupt a main-thread C-level wait (a Proactor overlapped op /
# blocking accept / subprocess.wait): pytest's faulthandler plugin arms a separate watchdog
# thread that dumps the NATIVE stack of every thread at `fault_timeout` (150s here, 90s on
# ubuntu -- always set ABOVE that leg's `pytest_timeout`, so the
# per-test dump is attributed first; this is the last-resort stack when the thread method is
# itself out-raced). NB the plugin exposes this as the `faulthandler_timeout` ini key, not a
# `--faulthandler-*` CLI flag, so it is passed with `-o`; it only DUMPS (never kills), safe.
# The step `timeout-minutes` (matrix `step_timeout`) is the outer backstop, held well under the
# job's own cap, so a hang both watchdogs somehow miss still fails the STEP (not the whole job at
# the silent cap). Keep the gap: the step must expire BEFORE the job, or the failure surfaces as
# an uninformative job-level kill with no step attribution.
#
# THAT GUARANTEE NOW COVERS BOTH GATED STEPS, and it did not until BACKLOG #344 proposal 5 landed.
# `Web console tests (pytest)` used to carry the SAME `step_timeout` while running AFTER
# `Tests (pytest)`, so reaching it had already spent setup + `Tests` and its own cap could not
# fire first at any job_timeout worth setting (~55 min on ubuntu, ~114 on Windows) -- a hang there
# was an unattributed job-level kill with no step conclusion. It now carries
# `matrix.webconsole_step_timeout`, sized to its own work, and the nesting arithmetic holds on all
# three legs; the derivation is in the note above that step.
#
# It only works while the cap stays clear of a HEALTHY run. On 2026-07-31 the ubuntu leg finished
# green in 775s against a 780s cap -- 5s of margin -- and the step was killed anyway, reported as
# a failure on a run whose last line was "9598 passed, 855 skipped". A watchdog that cannot
# separate "deadlocked" from "slow today" stops being a watchdog and becomes a coin flip, so the
# ubuntu budget is raised here. Re-check the margin when the suite grows.
#
# It then happened again on WINDOWS, 2026-08-01, because the sentence that used to close this
# paragraph asserted the Windows legs held "~2x headroom" and nobody re-derived it. PR #119 was
# killed at 26:07 against the 26:00 cap with ZERO tests failing. What moved was the suite, not the
# code under test: #74 landed tests/test_worktree_prune_merged.py (1,506 lines) and windows-2025
# went 19:35 -> 26:07 on the same branch. The claim was already false when it was written.
#
# Measured by timing the `Tests (pytest)` STEP, which is what step_timeout gates. NOT the job: the
# job runs several minutes longer and is capped separately, and at least three sessions misread job
# durations as step durations while triaging this (c53f752b's JOB ran 28:41 and PASSED, because
# job cap 30 vs step cap 26).
#
# POOL: every run of THIS workflow created 2026-08-01T00:00Z .. 2026-08-09T00:00Z UTC -- 688 runs.
# Enumerated with `gh api` over a deliberately WIDER page range (1,000 runs, back to 2026-07-26) and
# filtered locally on `created_at`. Jobs fetched with `?filter=all`, so an attempt killed at the cap
# is not hidden behind its passing re-run. Rows are each leg's `Tests (pytest)` STEP, kept when THAT
# STEP concluded, executions under 60s dropped (a docs-only PR skips the leg). 1,618 leg-executions;
# n below is per leg, passing rows only. Measured 2026-08-08 (BACKLOG #1096).
#
# leg max passing step n old cap old margin new cap new margin
# ubuntu-latest 20:28 35 25:00 1.221x 31:00 1.515x <- RE-DERIVED 2026-08-12
#
# THE UBUNTU ROW WAS RE-DERIVED 2026-08-12 AND THE OLD ONE IS KEPT ABOVE IN THE PROSE, NOT DELETED.
# Pool: every run of this workflow created after 2026-08-10 that contributed an ubuntu
# `Tests (pytest)` STEP -- 58 runs, 63 rows, 35 passing. Max passing 20:28; next highest 17:18;
# min 14:47; max non-success 17:01. Smaller pool than the 441-row original and stated as such.
#
# WHAT TRIGGERED IT, and the ordering matters: `main` ALREADY exceeded the old 16:08 anchor at
# 17:07 BEFORE the PR that reddened the gate. The record had rotted on its own; PR #343 (+274
# tests, +3:12) only carried the MARGIN across the 1.30x floor. So this is not "one PR was too
# big" -- the anchor was stale and the floor did its job by saying so. #1096 stays OPEN: the caps
# are re-derived, the DRIFT underneath them is still not fixed, and raising a cap is not fixing it.
#
# 20:28 IS PR #343's OWN RUN, and that is deliberate rather than an outlier being smuggled in: its
# 274 tests land on `main` when it merges, so 20:28 is the next baseline, not a one-off. Sizing on
# the pre-merge population would re-break the cap on contact -- the "three PRs each adding a
# minute" death this note already records.
# windows-2022 29:23 399 36:00 1.225x 55:00 1.872x
# windows-2025 35:47 CENSORED 360 36:00 1.006x 55:00 1.537x
#
# ONLY THE WINDOWS-2025 ROW IS CENSORED, AND THAT IS MEASURED, NOT ASSUMED. Largest ubuntu step
# FAILURE in 535 leg-executions is 14:21; largest windows-2022 failure in 542 is 25:48. Neither leg
# has ever touched its cap, so 16:08 and 29:23 are true maxima. windows-2025 was killed at the cap
# SEVEN times in this window -- 36:08 (31189317409, main b78214f5), 36:08 (31199321840, main
# 7ecff8ae), 36:08 (31202372465, PR #253), 36:07 (30955150892, 2026-08-04, the earliest), 36:07
# (31094875320, main fdec72ca), 36:07 (31192717684, PR #249), 36:01 (31149117314, PR #261) -- THREE
# of them push runs on main. So 35:47 is the largest step that FIT in 36:00, and 36:08 is a lower
# bound on the largest the suite wants. Smaller pools give smaller counts and each is right for its
# own pool: BACKLOG #1096's day pool has five, a 3-day pool has six. State the pool with the count.
#
# THE KILLS WERE SLOWNESS, NOT A WEDGE -- CHECKED, NOT ASSUMED. Run 31149117314's pytest finished
# GREEN and the step was killed 4.85 seconds later:
# 05:37:53.777Z 10666 passed, 831 skipped, 22 warnings in 2148.75s (0:35:48)
# 05:37:58.624Z ##[error]The action 'Tests (pytest)' has timed out after 36 minutes.
# No faulthandler native-stack dump appears in any kill log. That settles the hang-versus-slow
# question the #55 note above would otherwise leave open, and it has a second consequence that is
# easy to miss: the killed rows are genuine population members, so "max passing" is CENSORED, not
# conservative. Every ratio taken against it flatters itself.
#
# SUPERSEDED READING, kept because a number without its pool cannot be rechecked: the 2026-08-01
# pool (70 runs, pre-#131) read ubuntu 12:31 / W22 21:34 / W25 25:51 against the then-current
# 26:00 Windows cap. Those figures are correct for that pool and that code state. They are not
# comparable to the rows above, which are post-#131 and span eight days.
#
# THESE MAXIMA ARE LOWER BOUNDS, BECAUSE THE POOL IS RIGHT-CENSORED. Every run in it predates #131
# (28d186b5, landed 2026-08-02T00:35:28Z), so Windows ran under step 26:00 / job 30:00 and ubuntu
# under 19:00 / 22:00. A run that wanted longer than its cap was KILLED at the cap and then --
# correctly, by the filter above -- dropped for not concluding success. So 25:51 is the largest
# step that FIT in 26:00, not the largest the suite wants. Sizing a cap as a multiple of a
# censored maximum under-provisions by construction, and that is exactly how "1.06x" read as
# survivable right up until #119's leg was killed at 26:07. (#119 itself merged, 2026-08-02T01:45Z
# -- what the cap killed was a run, not the PR. Saying a PR "died" here reads as never-landed and
# had already propagated into docs/WORKTREES.md as exactly that claim.)
#
# THE CENSORED VALUES ARE VISIBLE IF YOU ASK FOR THEM, AND THEY MATTER. The jobs endpoint defaults
# to `filter=latest`, which hides a killed attempt behind its passing re-run; `?filter=all` shows
# both. #119's 26:07 is in this very pool that way (run 30717229521 attempt 1, `8c407fb5`, step
# conclusion FAILURE) beside its 22:25 attempt-2 success -- a 3:42 spread on identical code, and
# the first day AFTER the raise produced 26:23 TWICE, both concluding SUCCESS (runs 30728793103
# and 30731407003). So the population really does exceed the old 26:00 cap: the largest windows-2025
# `Tests (pytest)` execution observed to date is 26:23, not 25:51.
#
# THIS TABLE HAS BEEN WRONG TWICE. #131 published 12:27 / 18:39 / 24:35 over "11 passing runs";
# the first correction published the maxima above but over "101 runs" with n = 57 / 52 / 49. The
# maxima re-derive exactly; BOTH pool sizes were unreproducible. Three mechanisms produced that,
# all cheap to repeat:
#
# * THE POOL WAS A PAGE, NOT A QUESTION. #131's came from `gh run list --limit 20` -- a default
# page size, reported as though it had been chosen.
# * FILTERING ON *JOB* CONCLUSION DROPS THE TIGHTEST STEPS BY CONSTRUCTION. A step that nearly
# exhausts step_timeout is the most likely to push its job into job_timeout, so the job is
# cancelled while the step itself concluded success. Re-running this pool with a job-conclusion
# filter reproduces #131's 24:35 exactly; the step-conclusion filter gives 25:51. Measure the
# step; filter on the step. Two such rows exist on 2026-08-01 and one of them is the maximum.
# * A COUNT NOBODY RE-DERIVED. Neither "101 runs" nor n = 57 / 52 / 49 is reproducible under any
# pool definition tried. A table whose own point is "state your pool and your n" has to carry
# an n the next reader can recompute -- that column is how they would tell it had rotted.
#
# Only the windows-2025 row was ever a maximum; #131's ubuntu 12:27 was that same run's ubuntu leg,
# quoted as if it were that leg's worst case. Its 18:39 was not even that: that run's windows-2022
# step is 18:29, and 18:39 belongs to a different run entirely.
#
# State the MEASURED value, its POOL, its n and its DATE -- never a bare multiple. A multiple gives
# the next reader no way to tell when it has rotted; a multiple without its pool cannot even be
# rechecked.
#
# PROOF IT WAS THE CAP, NOT THE BRANCH. #119's windows-2025 leg was RE-RUN on the SAME commit
# against the SAME 26:00 cap: attempt 1 was killed at the cap, attempt 2 concluded SUCCESS. Same
# code, same config, same ceiling, two outcomes. The leg was not failing -- it was coin-flipping
# against the cap, exactly the state the ubuntu note above names, and the reason "re-run it and
# see" is not a diagnosis here. A green re-run at 26:00 does not mean the suite fits; it means
# that runner was fast enough that time.
#
# THE CAP IS SIZED ON THE POST-#1027 POPULATION, NOT ON WHAT RUNS TODAY. PR #253 (BACKLOG #1027) is
# open and MERGEABLE and sets testpaths = ["tests", "packaging/messagefoundry-webconsole/tests"],
# while keeping the `Web console tests (pytest)` step -- so once it lands the web console suite runs
# inside `Tests (pytest)` AS WELL. Sizing on the current population would let #253 land and re-break
# the cap on contact, which is the "three PRs each adding a minute" death recorded at the foot of
# this note. The one windows-2025 execution of that branch is one of the seven kills above, so the
# post-#1027 windows-2025 duration HAS NEVER BEEN OBSERVED UNCENSORED -- and windows-2022's sizing
# maximum (29:23) IS a post-#1027 row. One shared value, two code states, until this is fixed.
#
# leg largest observed Tests + largest observed web-console = post-#1027 anchor
# ubuntu 20:28 (uncensored) 2:22 (n=441) 22:50 <- RE-DERIVED 2026-08-12
# W22 29:23 (uncensored; already a post-#1027 row) 29:23
# W25 36:08 (RIGHT-CENSORED) 3:59 (n=360) 40:07 lower bound
#
# SIZING RULE: step_timeout = ceil_minute(1.35 x that leg's post-#1027 anchor). 1.35 is the multiple
# #131 used when it set 36:00 (1.364x over 26:23, the largest execution then observed).
# ubuntu 22:50 x 1.35 = 30:50 -> 31:00 (1.358x over the anchor, 1.515x over max passing)
# W22 29:23 x 1.35 = 39:40 -> 40:00 (takes the shared Windows value below)
# W25 40:07 x 1.35 = 54:09 -> 55:00 (1.371x over the anchor, 1.537x over max passing)
#
# UBUNTU IS CHANGED IN THE SAME ACT BECAUSE IT IS THE NEXT INSTANCE, NOT AS TIDYING. At 19:00 against
# its post-#1027 anchor of 18:30 it stands at 1.027x -- within seconds of where windows-2025 is now --
# and its job cap is ALREADY NEGATIVE on measured maxima (see the job paragraph below). Publishing
# "ubuntu unchanged, still positive" beside a corrected Windows row would be a compensating control
# resting on a false premise, which is the defect this repo names specifically.
#
# THE SPREAD RULE NOW HOLDS, WHERE AT 36:00 IT DID NOT. The rule is "headroom must exceed observed
# spread". At 36:00 it FAILED on windows-2025 (headroom 9:37 against a 10:27 spread) and the old
# revision kept the value anyway and said so. At 55:00, on the eight-day pool:
# windows-2025 headroom 55:00 - 35:47 = 19:13 spread 35:47 - 24:48 = 10:59 HOLDS (+8:14)
# windows-2022 headroom 55:00 - 29:23 = 25:37 spread 29:23 - 18:42 = 10:41 HOLDS (+14:56)
# The 24:48 low is same-suite runner variance, not a smaller suite: three consecutive push-to-main
# runs inside 2h42m gave windows-2025 34:15 / 24:48 / 34:29 while windows-2022 held a 1:00 band.
#
# RE-DERIVE IF a windows-2025 `Tests (pytest)` STEP is ever seen above 40:00, or if any Windows
# `test` JOB is ever seen above 50:00 -- those are the triggers, not a calendar reminder. Both are
# the 1.2x decay point of their cap (55:00 / 1.2 = 45:50, floored to 40:00 for margin against the
# measured drift; 66:00 / 1.2 = 55:00, floored to 50:00). The OLD trigger of 28:00 is retired
# because it was already universally exceeded: 58 of 59 passing steps on the day pool cleared it and
# the median itself sat above it, so it had become a permanently-tripped alarm, which is
# indistinguishable from no alarm at all.
#
# A ratio against one run says nothing about a distribution, and #119's leg was killed by the
# distribution, not by its own duration. Both Windows legs take the same values, but they are sized
# from DIFFERENT legs and that asymmetry is deliberate: the STEP cap is sized on windows-2025 (the
# slower step, 35:47 censored against 29:23), while the JOB cap is sized on windows-2022 (the worse
# non-step addends, setup 4:05 against 2:32). An earlier revision said "sized on windows-2025 as the
# worse of the pair" without qualification -- that is FALSE for the job cap.
#
# THE JOB CAP IS NOT A ROUNDING-UP OF THE STEP CAP, AND IT HAS FIRED. Two steps in this job carry
# a `timeout-minutes`, so the job must contain the SUM of two gated budgets plus setup -- a
# quantity neither step cap can bound on its own.
#
# UNTIL BACKLOG #344 PROPOSAL 5 THAT SUM WAS UNBOUNDABLE, because both steps drew on the SAME
# `step_timeout`: the worst case the caps admitted was 2 x step_timeout + setup, which no
# job_timeout worth setting could cover (2 x 25 = 50 > 37 on ubuntu, 2 x 55 = 110 > 66 on
# Windows). The caps were sized against the OBSERVED sum instead, which is a weaker guarantee and
# was recorded here as such. The web console step now carries `webconsole_step_timeout`, so the
# worst case the caps admit is finite and positive on every leg -- the arithmetic is in the note
# above that step, and it is the first time this file has been able to state it.
# Observed for real on run 30724385719 (main @ 8f01cef8, 2026-08-01):
#
# Tests (pytest) 00:01:21 -> 00:27:12 25:51 SUCCESS (9s under the 26:00 cap)
# Web console tests (pytest) 00:27:12 -> 00:30:14 CANCELLED
# JOB 00:00:06 -> 00:30:19 30:13 CANCELLED <- job_timeout 30 fired
#
# A GREEN first step, then an unattributed job-level kill during the second -- exactly the failure
# the nesting note above exists to prevent, arriving by the path that note does not consider. It
# does NOT happen when a step is killed (that ends the job and skips what follows, so those
# durations never sum); it happens when the first step PASSES near its budget.
#
# READ THAT EXHIBIT FOR ITS MECHANISM, NOT AS A VERDICT ON THE CAP IT REPLACED: it ran under the
# retired 26/30 pair and would have passed under #131's 40:00. What condemns 40:00 is the
# arithmetic below -- which is arithmetic, not an observed kill; no job has yet hit 40:00.
#
# job_timeout is therefore sized against BOTH gated steps plus setup, rather than as step_timeout
# plus a constant. Each addend below is that addend's MEASURED MAXIMUM over the same pool, taken
# over rows where `Tests` concluded success -- NOT over rows where both steps did, which silently
# drops the very run that motivates this whole paragraph (its web-console step was cancelled) and
# is the same censoring mistake as filtering the step table by job conclusion. Two earlier
# revisions used a 0:41 setup (the MEDIAN), web-console values that were each only third-highest
# on their leg, and a 1:04 Windows setup that the exhibit above contradicts on its face:
#
# THE BAND MOVED OFF step_timeout AND ONTO THE OVERHEAD, 2026-08-08 (BACKLOG #1096). The earlier
# formula multiplied the WHOLE sum (step_timeout + addends) by ~1.13-1.16. That applies a safety
# band to `step_timeout`, which is a HARD BOUND the runner enforces -- the largest step overrun in
# 1,618 measured executions is EIGHT SECONDS (36:08 against a 36:00 cap). Banding it is slack for an
# event that cannot happen, it grows with the cap (4:41 at 36:00, 7:09 at 55:00), and it makes the
# published ratio pool-dependent because the step term dominates it. Corrected rule:
#
# job_timeout = step_timeout + ceil_minute(1.5 x that leg's worst measured job OVERHEAD)
#
# where overhead = job wall clock minus the `Tests (pytest)` step, taken as the larger of (a) the
# worst SAME-ROW overhead on non-cancelled rows and (b) that leg's setup(max) + web-console(max).
# The 1.5 band is on the overhead only, and is 1.5 rather than 1.35 because setup is hosted-runner
# provisioning outside this repo's control: `Set up job` alone has been observed at 4:30 (ubuntu run
# 31109989006, inside a 5:22 setup).
#
# leg worst same-row setup(max)+wc(max) overhead used x1.5 room job
# ubuntu 7:12 (n=451) 5:22 + 2:22 = 7:44 7:44 11:36 12:00 31+12 = 43
# W22 6:16 (n=413) 4:05 + 2:51 = 6:56 6:56 10:24 11:00 55+11 = 66
# W25 6:29 (n=372) 2:32 + 3:59 = 6:31 6:31 9:47 10:00 (takes W22's 66)
#
# INSTRUMENT PRESERVATION, the whole point of the pairing -- worst-case job wall built from
# independent maxima with `Tests` passing one second under its cap, new values against old:
#
# ubuntu 5:22 + 30:59 + 2:22 = 38:43 vs 43:00 +4:17 | old: 32:43 vs 37:00 +4:17 (band held)
# W22 4:05 + 54:59 + 2:51 = 61:55 vs 66:00 +4:05 | old: 42:55 vs 46:00 +3:04
# W25 2:32 + 54:59 + 3:59 = 61:30 vs 66:00 +4:30 | old: 42:30 vs 46:00 +3:30
#
# UBUNTU WAS THE LEG ACTUALLY BROKEN, at -0:43, and nobody had measured it: BACKLOG #1096 was filed
# as a Windows problem. A negative row here means the JOB cap can fire before the STEP cap, and a
# job-level kill reports NO step conclusion -- so the instrument this item was measured with is
# destroyed exactly when it is needed. Max measured post-step teardown is 0:24, so all three new
# rows stay positive with room.
#
# THE THIRD ADDEND ABOVE IS THE OBSERVED web-console maximum, which is a DIFFERENT QUANTITY from
# the worst case the caps now admit -- one describes what has happened, the other what is
# permitted. Capping that step (BACKLOG #344 proposal 5) does not change how long it takes, so the
# rows above stand and `job_timeout` is UNCHANGED by it. The cap-based worst case is stated once,
# in the note above the `Web console tests (pytest)` step, and it is positive on every leg.
#
# NESTING INVARIANT, first gated step -- setup(max) + step_timeout must stay under job_timeout, so a
# `Tests` kill is a NAMED STEP failure rather than an unattributed job cancellation:
# ubuntu 5:22 + 25:00 = 30:22 < 37:00 (gap 6:38)
# W22 4:05 + 55:00 = 59:05 < 66:00 (gap 6:55)
# W25 2:32 + 55:00 = 57:32 < 66:00 (gap 8:28)
#
# 66 IS DERIVED, NOT ROUNDED UP FROM 55. The bare minimum whole minute that clears the worst sum is
# 61:00; it is rejected as thinner than any row this file has ever carried, and thinner than the
# three-addend model's own known error -- the worst observed single row sums to 40:16 against a real
# job wall clock of 40:22, so inter-step gaps make the model an UNDER-estimate by ~6s, not a
# conservative one.
#
# SUPERSEDED, kept because a number without its pool cannot be rechecked. The previous table read:
# ubuntu 19:00 + 2:00 + 1:20 = 22:20 (26:00, +3:40) | W22 36:00 + 2:33 + 1:09 = 39:42 (46:00,
# +6:18) | W25 36:00 + 3:33 + 1:20 = 40:53 (46:00, +5:07). Correct for its pool; superseded above.
#
# The `+4` habit that produced 40 was Windows-only
# and was carried through two cap changes unchecked (30/26 then 40/36); ubuntu has never been +4 --
# it went 15/13 to 22/19, so +2 then +3. Either way the number was derived from the OTHER BOUND
# rather than from the work, which is the defect, not the particular constant.
#
# WHAT THIS SIZING DID NOT FIX, AND WHAT SINCE FIXED IT. This sizing only made the job cap cover
# the OBSERVED sum. The nesting invariant held for `Tests (pytest)` on every leg and for
# `Web console tests (pytest)` on NONE of them, because both steps drew on one `step_timeout`:
# guaranteeing the second would have needed job_timeout above setup + 2 x step_timeout -- about
# 55:22 on ubuntu and 114:05 on Windows -- so a hang in the web console step surfaced as an
# unattributed job-level kill, the very failure the invariant is there to prevent. BACKLOG #344
# proposal 5 closed it the other way round: the web console step now has `webconsole_step_timeout`
# sized to its own work (a 2-to-4-minute suite has no business holding 55:00), which brings
# setup + step_timeout + webconsole_step_timeout under job_timeout on all three legs. The
# arithmetic, and the trigger for re-deriving it, live in the note above that step.
#
# AND KNOW WHAT IS NOT CAPPED AT ALL. `test` is the ONLY one of this file's ten jobs carrying any
# `timeout-minutes`; the other nine -- `changes`, `ide`, `sqlserver-store`, `postgres-store`,
# `load-test`, `load-test-sqlserver`, `windows-service-smoke`, `docker-smoke`, `ci-gate` -- run on
# GitHub's 6-HOUR default. That is recorded here so the omission is known rather than assumed
# deliberate; sizing them is out of scope for this change and belongs with BACKLOG #344.
#
# This cap is NOT what catches a hung test; pytest_timeout (120s) is, per test. The step cap only
# catches a whole-process deadlock both in-process watchdogs miss, which is why it can sit well
# above a healthy run. Sizing it tight buys no detection and costs false failures on green suites,
# twice now.
#
# The remaining margin is a SHARED budget across every PR that lands, and it is now ACCOUNTED
# FOR: `scripts/ci/step_margin.py` runs after both gated steps below and reds the leg when a
# step's own duration comes within 1.30x of its own cap (BACKLOG #344 proposal 1). Three PRs
# each adding a minute of Windows time is still individually blameless -- the difference is that
# the third one now says so instead of the fourth one dying at the cap with zero failing
# assertions. It is NOT a request to raise a cap; the underlying slowness is #320.
#
# THE CLOCK IS TAKEN IN ITS OWN STEP so neither gated `run:` body has to carry timing code that
# a kill would skip anyway. Elapsed is therefore the step plus its two transitions -- an UPPER
# bound on the step, so the reported margin is a LOWER bound on the true margin, and the error
# runs toward firing early rather than toward a false green.
#
# It marks THROUGH THE SCRIPT rather than `echo ... >> "$GITHUB_ENV"`, deliberately. That
# spelling routes a Windows path through a bash redirection on two of the three legs -- a
# construct no local run can exercise, whose failure mode is a silently absent variable, i.e. a
# check that stops measuring while still printing a verdict. Marking in Python keeps the path
# handling where the tests reach it, and a missing mark is a REFUSAL (exit 2), never a zero.
- name: Step margin -- start the clock
if: always()
run: python scripts/ci/step_margin.py --mark before-tests
- name: Tests (pytest)
id: tests
if: needs.changes.outputs.code == 'true' || github.event_name == 'push' || github.event_name == 'workflow_dispatch'
timeout-minutes: ${{ matrix.step_timeout }}
env:
QT_QPA_PLATFORM: offscreen
PYTHONFAULTHANDLER: "1"
# Pass the matrix-derived timeout knobs through env instead of expanding ${{ }} straight into
# the run: shell. The per-repo matrix is now built at runtime in `changes` (fromJSON), so zizmor
# can no longer statically prove these are literal ints and flags the in-run expansion as
# template-injection; env-passing removes the script sink. `defaults.run.shell: bash` applies on
# every OS, so $VAR resolves the same on the Windows legs (Git Bash).
FAULT_TIMEOUT: ${{ matrix.fault_timeout }}
PYTEST_TIMEOUT: ${{ matrix.pytest_timeout }}
# `--ignore-glob` subtracts the web console package, which runs as its OWN step below. BACKLOG
# #1027 added that package to the root `testpaths` so a bare local `pytest` stops silently
# excluding it; without this subtraction the SAME 356 tests would then run TWICE on every leg.
# Measured 2026-08-08 on this branch: bare collection 11,956, console-only 356, with this flag
# 11,600.
# NOT `--ignore=<path>` -- measured, it does NOT prune a directory that `testpaths` names as a
# collection root, and still collected all 11,956. The obvious spelling is the one that fails.
# NOT `pytest tests` either: hardcoding the engine path would make CI silently miss any future
# third entry in `testpaths`, which is the exact drift #1027 exists to prevent. Subtracting FROM
# `testpaths` keeps it the single source of truth for what the suite is.
# `tests/test_ci_engine_step_excludes_webconsole.py` pins all three of those decisions.
run: pytest -q --ignore-glob='*messagefoundry-webconsole*' -o faulthandler_timeout="$FAULT_TIMEOUT" --timeout="$PYTEST_TIMEOUT"
- name: Step margin -- boundary between the two gated steps
if: always()
run: python scripts/ci/step_margin.py --mark between
# The web console's OWN suite (Option B, ADR 0065): the moved /ui tests live in the package's
# tests/. They ARE in the root `testpaths` since BACKLOG #1027, so a bare local `pytest` collects
# them -- the engine step above subtracts them explicitly via `--ignore-glob` rather than relying
# on `testpaths` to exclude them, which it no longer does. They still run as a SECOND step on the
# SAME leg, under the package's OWN pytest configuration. The package was installed editable in the install
# step above (`-e packaging/messagefoundry-webconsole`), so both suites exercise the same
# engine build. Its pyproject sets asyncio_mode="auto" + session loop scopes; the timeout flags
# mirror the engine step (a hung ASGI test fails fast + named, not at the silent job cap).
# SEAM MATRIX — KNOWN GAP, deliberately recorded rather than silently carried. This runs the
# package against the ONE installed engine (whatever ENGINE_UI_SEAM is at HEAD). The original
# comment said "only ENGINE_UI_SEAM==1 exists today ... when a seam 2 lands, expand this into a
# matrix" — that trigger fired long ago and was never actioned: the console now supports a
# RANGE (SUPPORTED_ENGINE_SEAMS), so the back-compat claim that an older engine still renders
# is NOT exercised anywhere. Closing it means installing the MIN and MAX supported engine
# builds and running the package suite against each.
#
# THIS STEP HAS ITS OWN CAP AS OF BACKLOG #344 PROPOSAL 5, and that is a structural change, not a
# tidy-up. It used to carry `matrix.step_timeout` -- the same 25/55 the engine suite holds -- so a
# 2-to-4-minute suite sat behind a budget it could never reach: reaching this step has already
# spent setup plus `Tests`, so its cap could not fire first at any `job_timeout` worth setting
# (~55 min on ubuntu, ~114 on Windows). A hang HERE was therefore an unattributed JOB-level kill
# with no step conclusion at all, which is exactly the failure the nesting invariant exists to
# prevent, arriving by the one path that invariant did not cover.
#
# SIZING. Same rule as `step_timeout` -- ceil_minute(1.35 x the leg's measured max) -- then
# FLOORED at 5:00, because at these absolute durations 1.35x is under a minute of real headroom
# and hosted-runner provisioning noise alone has been observed at 4:30 (`Set up job`, ubuntu run
# 31109989006). Maxima are the 2026-08-08 pool recorded above and in
# `scripts/ci/step_margin_baseline.toml`:
#
# leg measured max x1.35 floored cap margin over the measured max
# ubuntu 2:22 (n=441) 3:12 5:00 5 2.113x
# W22 2:51 3:51 5:00 6 2.105x (takes the shared Windows value)
# W25 3:59 (n=360) 5:23 6:00 6 1.506x
#
# THOSE MAXIMA ARE THEMSELVES RIGHT-CENSORED, and by the JOB cap rather than by a step cap: this
# step runs second, so a job-level kill lands inside it. Run 30724385719 is the exhibit -- `Tests`
# SUCCESS at 25:51, then this step CANCELLED at 30:14. So the numbers above are lower bounds, and
# the floor is doing real work rather than being generosity.
#
# NESTING INVARIANT, SECOND GATED STEP -- setup(max) + step_timeout + webconsole_step_timeout must
# stay under job_timeout. This is the line that could not be satisfied before, on ANY leg:
# ubuntu 5:22 + 25:00 + 5:00 = 35:22 < 37:00 (gap 1:38)
# W22 4:05 + 55:00 + 6:00 = 65:05 < 66:00 (gap 0:55)
# W25 2:32 + 55:00 + 6:00 = 63:32 < 66:00 (gap 2:28)
# It now holds on all three, so a hang in the web console suite fails as a NAMED STEP.
#
# WORST CASE THE CAPS ADMIT, with both gated steps passing one second under their own caps --
# the quantity the job paragraph above could not previously bound at all, because the second
# gated step's cap was the first one's:
# ubuntu 5:22 + 30:59 + 4:59 = 41:20 vs 43:00 (+1:40) was 5:22 + 24:59 + 24:59 = 55:20 NEGATIVE
# W22 4:05 + 54:59 + 5:59 = 65:03 vs 66:00 (+0:57) was 4:05 + 54:59 + 54:59 = 114:03 NEGATIVE
# W25 2:32 + 54:59 + 5:59 = 63:30 vs 66:00 (+2:30) was 2:32 + 54:59 + 54:59 = 112:30 NEGATIVE
# The old table stated a worst case built from the MEASURED web-console maxima, which is a
# different quantity: it described what had been observed, not what the caps permitted.
#
# THE RE-DERIVE TRIGGER IS THE MARGIN CHECK ITSELF, not a second number written here to drift
# against it. The check below reds at 1.30x of the cap -- 3:51 on ubuntu, 4:37 on Windows -- and
# a run reaching that is 1.6x to 1.9x this leg's maximum over hundreds of observations, which is
# an event rather than a slow morning. It also prints the percent-of-cap on every run, so the
# approach is visible before the refusal. The old habit of writing a separate trigger into this
# file is exactly what produced a permanently-tripped 28:00 alarm nobody could act on.
- name: Web console tests (pytest)
id: webconsole
if: needs.changes.outputs.code == 'true' || github.event_name == 'push' || github.event_name == 'workflow_dispatch'
timeout-minutes: ${{ matrix.webconsole_step_timeout }}
env:
QT_QPA_PLATFORM: offscreen
PYTHONFAULTHANDLER: "1"
# env-passed (not a ${{ }} expansion into run:) — see the Tests step above for why.
FAULT_TIMEOUT: ${{ matrix.fault_timeout }}
PYTEST_TIMEOUT: ${{ matrix.pytest_timeout }}
run: pytest packaging/messagefoundry-webconsole/tests -q -o faulthandler_timeout="$FAULT_TIMEOUT" --timeout="$PYTEST_TIMEOUT"
# THE MARGIN CHECK (BACKLOG #344 proposal 1). Last in the job so a LOW margin cannot skip a suite
# that has not run yet -- a step `if:` with no status function carries an implicit `success()`,
# so a failing check placed between the two gated steps would silently skip the second one.
#
# It keys on `steps.<id>.outcome` -- THE STEP'S OWN CONCLUSION, never the job's. That substitution
# is not pedantry: a step that nearly exhausts its cap is the most likely to push its job into
# `job_timeout`, so filtering on the job deletes the tightest rows by construction, and doing
# exactly that reproduced a published maximum of 24:35 where the truth was 25:51.
#
# A skipped step (docs-only PR) reports NO OBSERVATION in words rather than a healthy-looking
# ratio, and the script runs its own red/green control pair on every invocation and prints both
# into the job summary -- a gate that has never been red is a claim, not a control.
- name: Step margin -- both gated steps
if: always()
env:
TESTS_OUTCOME: ${{ steps.tests.outcome }}
WEBCONSOLE_OUTCOME: ${{ steps.webconsole.outcome }}
STEP_CAP: ${{ matrix.step_timeout }}
WEBCONSOLE_CAP: ${{ matrix.webconsole_step_timeout }}
LEG: ${{ matrix.os }}
run: |
rc=0
# `|| rc=$?` OUTSIDE the command, so a non-zero exit is captured rather than swallowed by
# `set -e` or absorbed into a pipeline. Both checks always run: reporting only the first
# would hide the leg that is actually tight.
python scripts/ci/step_margin.py --step "Tests (pytest)" --leg "$LEG" \
--cap-minutes "$STEP_CAP" --outcome "$TESTS_OUTCOME" \
--since before-tests --until between || rc=$?
python scripts/ci/step_margin.py --step "Web console tests (pytest)" --leg "$LEG" \
--cap-minutes "$WEBCONSOLE_CAP" --outcome "$WEBCONSOLE_OUTCOME" \
--since between --until now || rc=$?
exit $rc
# Build + type-check the VS Code extension, and run its integration tests. The Python jobs never
# touch ide/, so a dep bump or IDE code change that breaks the bundle or the types would otherwise
# pass CI unbuilt (this job exists because an esbuild bump merged "green" without anything having
# compiled it). The build runs on both OSes; the @vscode/test-electron suite (which downloads and
# launches a real headless VS Code to assert the extension activates and its commands register/run)
# runs on the Windows leg — Electron launches headless there with no xvfb, and Windows is a
# deployment target.
ide:
name: ide build (${{ matrix.os }})
# `ide` is NOT a required check and ci-gate does NOT `needs: ide`, so skipping it — or varying its
# matrix per repo (below) — can't wedge any required context. It runs when it can be affected:
# push-to-main + workflow_dispatch (full coverage), and PRs that touch ide/** or this workflow (the
# `changes.ide` path-gate) — a pure-Python PR can't change the isolated npm project's result, so it's
# skipped there. Not on the nightly cron: the engine heavy legs need that safety net, an isolated npm
# build gated on its own paths does not. The per-repo os matrix below runs the 2x-billed
# windows-latest electron leg here (this repo is public, so hosted runners are free) and drops it on
# a fork, where those minutes come out of the fork owner's own allowance.
needs: changes
if: github.event_name == 'workflow_dispatch' || needs.changes.outputs.ide == 'true'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
# Per-repo os matrix (built in `changes`): ubuntu + windows-latest on THIS repo, ubuntu-only on a
# fork. The windows-latest leg is the 2x-billed one that runs the @vscode/test-electron integration
# suite; this repo is public so hosted minutes are free, while on a fork they are billed to the
# fork owner. `ide` is not a required check and ci-gate does not `needs` it, so dropping the
# windows leg on a fork can't wedge anything.
matrix: ${{ fromJSON(needs.changes.outputs.ide_matrix) }}
defaults:
run:
working-directory: ide
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
cache-dependency-path: ide/package-lock.json
- name: Install (clean, from lockfile)
run: npm ci
- name: Type-check (tsc --noEmit)
run: npm run typecheck
- name: Bundle (esbuild)
run: npm run compile
# The vscode-free suites (the pure model layer: the engine link state + its two frozen boundary
# allowlists, the settings-scope SEC-005 invariant, the graph/steps/HL7 models, …). They need no
# Extension Host, so they run on EVERY leg — including the ubuntu-only leg a fork gets.
# Before this step they ran NOWHERE on an ubuntu-only matrix: `npm test` is Windows-only, so the
# entire node-side estate was type-checked and never executed there.
# ADR 0110's allowlists are only "asserted in CI" because of this line.
- name: Unit tests (node-side, no VS Code)
run: npm run test:unit
- name: Integration tests (headless VS Code)
if: runner.os == 'Windows'
run: npm test
# Path filter for the container-pulling server-DB leg(s). A docs/unrelated PR shouldn't pull the SQL
# Server image (mcr.microsoft.com anonymous pulls are rate-limited — repeated PRs flake on
# "toomanyrequests"). This job decides whether the SQL Server leg needs to run: ALWAYS true on
# workflow_dispatch (full coverage); FALSE on push-to-main (the container legs run nightly +
# PR-path-gated, not per merge); on a PR, true only when store / cluster / server-DB tests /
# this workflow changed. (NB: if `sqlserver-store` is later made a *required* check in branch
# protection, a path-skipped run reports "skipped" — use a ruleset that treats skips as success, or
# switch to an always-run job whose heavy steps are step-gated, since the service container starts
# before steps.)
changes:
name: detect server-DB + docker changes
runs-on: ubuntu-latest
outputs:
serverdb: ${{ steps.f.outputs.serverdb }}
docker: ${{ steps.f.outputs.docker }}
# `code` is the docs-only short-circuit signal: TRUE unless this PR touches ONLY non-code paths
# (Markdown / docs / licence / editorconfig / git metadata). It gates the EXPENSIVE STEPS of the
# required `test` legs (and the whole `ide` job) — the jobs still run + report their (required)
# context, but on a docs-only PR they skip install+lint+type+test and go green in seconds. TRUE on
# push-to-main / workflow_dispatch (full coverage); FALSE on the nightly `schedule` (load-legs-only
# cron — the functional matrix must not re-run nightly).
code: ${{ steps.f.outputs.code }}
# `ide`: run the ide build job only when a PR touches ide/** or this workflow (true on
# push/dispatch for full coverage; false on the nightly cron). Not a required check.
ide: ${{ steps.f.outputs.ide }}
# `matrix`: the `test` job's include list, per-repo — full ubuntu+windows matrix HERE (this repo is
# public, so hosted runners are free; the self-hosted Windows runners are retired), ubuntu-only on
# a fork. Consumed as `matrix: ${{ fromJSON(needs.changes.outputs.matrix) }}` in `test`.
matrix: ${{ steps.f.outputs.matrix }}
# `ide_matrix`: the `ide` job's os matrix, per-repo — ubuntu + windows-latest HERE, where the
# 2x-billed VS Code electron leg runs free; ubuntu-only on a fork. Consumed as
# `matrix: ${{ fromJSON(needs.changes.outputs.ide_matrix) }}` in `ide`.
ide_matrix: ${{ steps.f.outputs.ide_matrix }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- id: f
# Hoist the two workflow expressions into env (zizmor: no `${{ }}` interpolated into the run body
# — a branch name / base sha is attacker-influenceable on a fork PR, so it must arrive as data).
env:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
# test matrix, per-repo — computed FIRST (independent of the event, before any early exit).
# THIS repo (MEFORORG/MessageFoundry, the source since the cutover): full ubuntu + windows-2022
# + windows-2025 on FREE hosted runners — free because the repo is public.
# Anywhere else (a FORK): the UBUNTU leg ONLY, so a contributor's own minutes are not spent on
# the 2x-billed Windows legs. This branch used to mean "the private source repo"; that repo is
# now an inactive archive with Actions disabled, so a fork is the only thing that reaches it.
# The self-hosted Windows NucBox runners are retired either way.
# `hosted` = the runner labels (an array; see the `test` job's runs-on).
# $GITHUB_REPOSITORY is a built-in runner env var, read here as plain shell (NOT a workflow-
# expression interpolation into the run body), so it is zizmor-safe and cannot be misparsed as
# an Actions expression the way a literal double-brace token in a run: block would be.
# `webconsole_step_timeout` is the web console suite's OWN cap (BACKLOG #344 proposal 5). It
# used to take `step_timeout` -- a 2-to-4-minute suite holding a 25-to-55-minute budget --
# which is why the nesting invariant could not hold for it on any leg. Derived in the note
# above the `Web console tests (pytest)` step, which is also where to re-derive it.
U='{"os":"ubuntu-latest","python-version":"3.14","hosted":["ubuntu-latest"],"job_timeout":43,"step_timeout":31,"webconsole_step_timeout":5,"pytest_timeout":60,"fault_timeout":90}'
W22='{"os":"windows-2022","python-version":"3.14","hosted":["windows-2022"],"job_timeout":66,"step_timeout":55,"webconsole_step_timeout":6,"pytest_timeout":120,"fault_timeout":150}'
W25='{"os":"windows-2025","python-version":"3.14","hosted":["windows-2025"],"job_timeout":66,"step_timeout":55,"webconsole_step_timeout":6,"pytest_timeout":120,"fault_timeout":150}'
if [ "${GITHUB_REPOSITORY:-}" = "MEFORORG/MessageFoundry" ]; then
echo "matrix={\"include\":[$U,$W22,$W25]}" >> "$GITHUB_OUTPUT"
else
echo "matrix={\"include\":[$U]}" >> "$GITHUB_OUTPUT"
fi
# ide matrix, per-repo (computed here with the test matrix, before any early exit): the
# 2x-billed windows-latest electron leg runs only HERE, where hosted minutes are free; a fork
# builds + type-checks the extension on ubuntu only. Consumed as
# matrix: fromJSON(needs.changes.outputs.ide_matrix) in the ide job. (No shell vars here, so a
# single-quoted literal JSON needs no escaping and carries no run-block workflow expression.)
if [ "${GITHUB_REPOSITORY:-}" = "MEFORORG/MessageFoundry" ]; then
echo 'ide_matrix={"os":["ubuntu-latest","windows-latest"]}' >> "$GITHUB_OUTPUT"
else
echo 'ide_matrix={"os":["ubuntu-latest"]}' >> "$GITHUB_OUTPUT"
fi
# Manual dispatch = full coverage: force every gate true so the whole workflow runs.
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "serverdb=true" >> "$GITHUB_OUTPUT"
echo "docker=true" >> "$GITHUB_OUTPUT"
echo "code=true" >> "$GITHUB_OUTPUT"
echo "ide=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Push-to-main: the FUNCTIONAL gate (code) stays true — the required test legs re-run on
# the merge commit — but serverdb/docker are FALSE: the container legs (sqlserver-store,
# postgres-store, docker-smoke) run nightly + on dispatch + via the PR path-gates, so a
# push must no longer fire them. BOTH halves of that guard matter: their `if:` has no push
# arm AND these outputs are false on push — restoring EITHER half re-adds the per-merge
# cost. A merge touching those surfaces was already path-gated on its own PR minutes ago.
if [ "$EVENT_NAME" = "push" ]; then
echo "serverdb=false" >> "$GITHUB_OUTPUT"
echo "docker=false" >> "$GITHUB_OUTPUT"
echo "code=true" >> "$GITHUB_OUTPUT"
echo "ide=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$EVENT_NAME" = "schedule" ]; then
# Nightly run: the heavy legs fire via their own `schedule` if-arm; these outputs stay
# false so the functional matrix and the PR path-gate arms don't ALSO fire.
echo "serverdb=false" >> "$GITHUB_OUTPUT"
echo "docker=false" >> "$GITHUB_OUTPUT"
echo "code=false" >> "$GITHUB_OUTPUT"
echo "ide=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Remaining case: pull_request. Compute the path-based filters from the PR diff.
changed=$(git diff --name-only "$BASE_SHA...HEAD")
# NB: the tests/test_(...) alternation MUST list every file the sqlserver-store /
# postgres-store pytest steps below run (the ADR 0057/0058/0060/0066 throughput levers AND
# the ADR 0013/0016 RTE capture/re-ingress case); those suites are MEFOR_TEST_SQLSERVER-gated
# and run ONLY on the server-DB legs, so a file not matched here edits with NO real SS/PG
# coverage (it merely skips the gated leg). Keep this in sync with those steps
# (docs/archive/throughput/throughput-build-plan.md).
# The transports/ + config/wiring arms are the CAPTURE SURFACE: capture_response / reingress_to
# are declared there (ADR 0013), and SQL Server / Postgres both declare supports_response_capture
# + supports_pt_reingress True — so a regression in that surface is a SERVER-DB regression and must
# pull these legs, not just the SQLite suite.
if echo "$changed" | grep -qE '^(messagefoundry/store/|messagefoundry/pipeline/(cluster|wiring_runner|stage_dispatcher)|messagefoundry/config/(settings|wiring)|messagefoundry/transports/(base|database|dicomweb|fhir|http_auth|mllp|rest|soap|tcp|x12)|tests/test_(sqlserver|postgres|cluster|database_connector|database_source|pooled|stage_dispatcher|batch_claim|claim_fifo|inline_fast_path|seq_only_fifo|fifo_index|per_lane_wake|response_capture|reingress|x12_rte|shard_recovery|shard_cert|adr0071|adr0075|adr0114|adr0157|dr_server_seed_gate|dr7_server_config_only_backup|backup_runner_server_db|connscale|load_failover|load_runner)|\.github/workflows/ci\.yml)'; then
echo "serverdb=true" >> "$GITHUB_OUTPUT"
else
echo "serverdb=false" >> "$GITHUB_OUTPUT"
fi
# The container image smoke runs on PRs that touch the image, the per-profile locks, packaging,
# or this workflow (engine-wide regressions are already covered by the `test` job + push-to-main).
if echo "$changed" | grep -qE '^(docker/|\.dockerignore|pyproject\.toml|requirements\.lock|\.github/workflows/ci\.yml)'; then
echo "docker=true" >> "$GITHUB_OUTPUT"
else
echo "docker=false" >> "$GITHUB_OUTPUT"
fi
# The ide job (VS Code extension build+test) only depends on ide/** (its own npm project)
# and this workflow; a pure-Python PR can't change its result, so don't run it there.
if echo "$changed" | grep -qE '^(ide/|\.github/workflows/ci\.yml)'; then
echo "ide=true" >> "$GITHUB_OUTPUT"
else
echo "ide=false" >> "$GITHUB_OUTPUT"
fi
# `code`: CONSERVATIVE docs-only detector. Default to running everything (code=true), and only
# short-circuit when EVERY changed path matches the docs/non-code allowlist below. We compute it
# by inverting: list the files that are NOT docs-only; if that list is empty (and the diff is
# non-empty), it's a docs-only PR. Anything outside the allowlist — any *.py, ide/**, config,
# lockfiles, OTHER workflows, scripts, samples, harness — counts as CODE and runs the full suite.
# A path appears in `changed` even if only deleted/renamed, so a pure doc rename still short-
# circuits, and a code deletion still runs.
# Allowlisted (docs-only) paths: any *.md anywhere, docs/**, top-level LICENSE/NOTICE/AUTHORS,
# .editorconfig, .gitattributes, .github/{ISSUE_TEMPLATE,PULL_REQUEST_TEMPLATE,...}.md.
# NOT .gitignore -- #327 removed it from the regex and this line went on listing it for
# weeks, the THIRD instance in this file of a comment stating the opposite of its code.
# `.gitattributes` IS still listed here and is nonetheless CODE: `alwayscodepath` below is
# checked first and wins. Precedence, not deletion -- see the reasoning there.
# Be conservative: when in doubt a path is CODE. Empty diff (shouldn't happen on a PR) => code=true.
# `.gitignore` is NOT in this allowlist, deliberately (BACKLOG #327). Six of its rules are the