-
Notifications
You must be signed in to change notification settings - Fork 1
907 lines (856 loc) · 47.7 KB
/
Copy pathtest.yml
File metadata and controls
907 lines (856 loc) · 47.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
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
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
inputs:
integration_repeats:
description: "Run the integration suite N times in one job (native-spawn flake repro, ops-wumd). Default 1."
required: false
default: "1"
jobs:
test-unit:
# Matrixed name (#672): GitHub Actions always suffixes a
# matrixed job's display name/check context with the matrix values (e.g.
# "Unit Tests (22)"), so this can no longer be the bare "Unit Tests" the
# branch-protection required check expects — see the `test-unit-gate` job
# below, which reports that fixed context instead.
name: "Unit Tests (node ${{ matrix.node-version }})"
runs-on: ubuntu-latest
# No job timeout here either (ops-fumh): this job's `sfw bun install` below
# hung 16+ min on 2026-07-04, blocking merge on a required check. Unit tests
# run in ~1 min; 10 min bounds a hung install/runner.
timeout-minutes: 10
strategy:
# fail-fast: false — a failure on one Node major must not cancel the
# others still running; we want to see all three results, not just the
# first failure (#672, Nathan: a recent incident had flair
# running in production on a Node major CI never exercised).
fail-fast: false
matrix:
# Currently-maintained Node releases as of 2026-07 (nodejs.org/en/about/eol,
# https://endoflife.date/nodejs): 22 = Maintenance LTS (EOL 2027-04),
# 24 = Active LTS (EOL 2028-04), 26 = Current (released 2026-05, becomes
# Active LTS 2026-10). Node 25 was Current-only and reached EOL 2026-06-01
# when 26 superseded it — deliberately excluded, it's already dead upstream.
node-version: ["22", "24", "26"]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: "Check for local file: deps"
run: |
if grep -q 'file:' package.json; then
echo "ERROR: package.json contains local file: reference"
grep 'file:' package.json
exit 1
fi
- name: "Check for compiled artifacts in resources/"
run: |
JS_FILES=$(find resources -name '*.js' -not -path 'resources/node_modules/*' 2>/dev/null)
if [ -n "$JS_FILES" ]; then
echo "ERROR: compiled .js files found in resources/ — these should not be committed"
echo "$JS_FILES"
exit 1
fi
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ matrix.node-version }}
- name: "Check workspace internal-dep version lockstep"
# Catches the bug shape from 0.8.0: a workspace package declaring an
# internal @tpsdev-ai/* dep at a version different from the version
# that workspace package ships. Local dev hides it (bun symlinks);
# only consumers of the published tarball see the staleness.
run: node scripts/check-workspace-deps.mjs
- name: "Check production dep bake time (≥7 days)"
# Supply-chain bake-time guard. Fails if any external pinned production
# dep was published <7 days ago — catches the "compromise published,
# not yet detected" window. See docs/supply-chain-policy.md.
# Tunable via FLAIR_DEP_MIN_AGE_DAYS env. Workspace-internal deps exempt.
run: node scripts/check-dep-ages.mjs
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2
with:
bun-version: "1.3.10"
- uses: socketdev/action@ba6de6cc0565af1f42295590380973573297e31f # v1.3.2
with:
mode: firewall-free
- run: sfw bun install --frozen-lockfile
- run: bun test test/unit/
# packages/flair-mcp/test/*.test.ts (mcp.test.ts, mcp-node-preflight.test.ts,
# session-start-hook.test.ts) weren't gated by CI — `bun test test/unit/`
# above only covers the repo root (closes #491, follow-up to #490 per Kern's
# review). All three files are hermetic: pure-logic assertions, an injected
# mock bootstrap client, and a local `node` spawn against the built shim —
# no live Flair daemon or network I/O, so they belong in this job rather
# than test-integration.
- name: "Build flair-client (workspace dep flair-mcp imports by its built dist/)"
run: cd packages/flair-client && bun run build
- name: "flair-mcp package tests"
run: cd packages/flair-mcp && bun test
# The other 5 packages (flair-client, langgraph-flair, n8n-nodes-flair,
# openclaw-flair, pi-flair) also have real test suites CI only typechecked
# (#619, generalizing #605/#491's fix for flair-mcp). flair-client's own
# tests import its source directly, so the build above isn't required for
# it — but langgraph-flair, n8n-nodes-flair, openclaw-flair, and pi-flair
# all depend on the WORKSPACE package @tpsdev-ai/flair-client, which
# resolves via its package.json main → dist/index.js (bun workspace
# symlinks point at source, not emitted JS): confirmed by temporarily
# unbuilding flair-client, which broke openclaw-flair's test with
# "Cannot find module '@tpsdev-ai/flair-client'". The build step above
# already covers all four. All five suites are hermetic (pure-logic
# assertions / mocked fetch, no live Flair daemon or network I/O) and
# were verified green on main before wiring here.
- name: "flair-client package tests"
run: cd packages/flair-client && bun test
- name: "Remaining workspace package tests (flair-client dep already built above)"
# flair-bench has no @tpsdev-ai/flair-client dependency (it's a standalone
# embedding-benchmark CLI/library, node-llama-cpp is its only prod dep) —
# riding this same loop anyway since its `bun test` is equally hermetic
# (pure-logic scorer/prefix/recommend-heuristic/share-schema assertions +
# two source-text sync-checks against test/bench/recall-harness/, no live
# model load or network I/O) and it doesn't need its own dedicated step.
run: |
FAIL=0
for pkg in langgraph-flair n8n-nodes-flair openclaw-flair pi-flair flair-bench; do
echo "::group::test $pkg"
(cd "packages/$pkg" && bun test) || FAIL=1
echo "::endgroup::"
done
if [ "$FAIL" -ne 0 ]; then
echo "::error::One or more workspace packages have failing tests"
exit 1
fi
# Required-status-check adapter (#672): branch protection's
# required_status_checks pins the exact context "Unit Tests", which a
# matrixed job can no longer produce directly (GHA reports each leg as
# "Unit Tests (22)" / "(24)" / "(26)"). This gate job re-emits the fixed
# "Unit Tests" name and fails unless every matrix leg succeeded, so branch
# protection needs no change and still genuinely gates on all three majors.
test-unit-gate:
name: Unit Tests
needs: test-unit
runs-on: ubuntu-latest
if: always()
steps:
- name: Require all Node-version legs to pass
run: |
echo "test-unit matrix result: ${{ needs.test-unit.result }}"
if [ "${{ needs.test-unit.result }}" != "success" ]; then
echo "::error::One or more Unit Tests Node-version legs did not succeed"
exit 1
fi
test-integration:
name: Integration Tests
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2
with:
bun-version: "1.3.10"
# Node 22+ is required by flair's engines. Integration tests spawn Harper
# natively (matches how real users run Flair) instead of via the Docker
# image, because the harperfast/harper Docker environment (bundled Node 24
# + Debian bookworm) has historically triggered an HNSW concurrent-write
# race that doesn't reproduce on native Node 22 spawns. The Harper
# server code is byte-identical across artifacts — only the runtime
# environment differs. The native-spawn choice is a version-agnostic
# mitigation; the E2E/smoke jobs run the Docker image (its tag is derived
# from the @harperfast/harper npm dep at CI time — see the resolve step
# below, #625 — so it can't drift) and are where the race, if still
# present, surfaces.
# Tracked upstream: https://github.com/HarperFast/harper/issues/386
#
# Deliberately NOT part of the node-version matrix added elsewhere in this
# file (#672): this job's Node 22 pin is the load-bearing half
# of the #386 mitigation above (native node 22 vs. the Docker image's
# bundled Node 24) — retargeting it to 24 or 26 would undo that specific,
# already-validated regression test rather than add coverage. Node-major
# breadth for the "flair actually runs under Node vX" question is instead
# covered by `pack-smoke` below, which matrices 22/24/26 and is the more
# representative real-user path (spawns the packed tarball's CLI directly).
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- uses: socketdev/action@ba6de6cc0565af1f42295590380973573297e31f # v1.3.2
with:
mode: firewall-free
- run: sfw bun install --frozen-lockfile
- name: Install linux-x64 native binary for embeddings
run: bun add --no-save @node-llama-cpp/linux-x64@3
- name: Build Flair resources and CLI
run: bun run build && bun run build:cli
- name: Verify critical exports in dist
run: |
echo "Checking embeddings-provider exports..."
grep -q "export.*function getEmbedding" dist/resources/embeddings-provider.js || { echo "FATAL: getEmbedding missing"; exit 1; }
grep -q "export.*function getMode" dist/resources/embeddings-provider.js || { echo "FATAL: getMode missing"; exit 1; }
echo "All critical exports present ✅"
- name: Pre-download embedding model
# Resolves cache → first-party GitHub release mirror → HuggingFace
# (last), with sha256 verification regardless of source (flair#715 —
# an HF outage on 2026-07-13 took this lane and four others red for
# hours with zero code at fault; a bare HF curl had already flaked
# this job before that, #463/#465).
run: bash "$GITHUB_WORKSPACE/scripts/ci/fetch-model.sh" nomic-embed-text-v1.5.Q4_K_M.gguf "$GITHUB_WORKSPACE/models"
- name: Run integration tests (native Harper spawn)
# No HARPER_HTTP_URL — harper-lifecycle spawns Harper from node_modules
# per the npm-installed @harperfast/harper. Avoids the Docker
# runtime environment that has triggered HarperFast/harper#386.
#
# integration_repeats (workflow_dispatch input, default 1) runs the
# suite N times in one job to reproduce the intermittent native-spawn
# flake (ops-wumd) under #467's diagnostics. Normal push/PR runs use 1,
# so this is a no-op for them. The input is passed via env (not
# interpolated into the script body) and validated numeric, to avoid
# ${{ ... }} shell-injection.
env:
INTEGRATION_REPEATS: ${{ github.event.inputs.integration_repeats || '1' }}
run: |
repeats="${INTEGRATION_REPEATS}"
case "$repeats" in ''|*[!0-9]*) repeats=1 ;; esac
echo "Running integration suite ${repeats}x"
for i in $(seq 1 "${repeats}"); do
echo "::group::integration attempt ${i}/${repeats}"
bun test test/integration/
echo "::endgroup::"
done
# Single source of truth (#625): derive the Harper Docker image tag from the
# @harperfast/harper version declared in package.json, so the Docker-based
# E2E/Playwright validation can never drift behind the Harper that ships to
# npm users (the skew that bit v0.14.0: 5.0.1 image vs 5.1.14 dep).
- name: Resolve Harper image tag from package.json
id: harper-image
run: |
TAG=$(node -p "require('./package.json').dependencies['@harperfast/harper']")
case "$TAG" in
[0-9]*) : ;; # exact pin — usable directly as a Docker image tag
*) echo "::error::@harperfast/harper is '$TAG' in package.json — not an exact version pin, cannot derive a Docker image tag"; exit 1 ;;
esac
echo "Harper Docker image tracks the npm dep: harperfast/harper:$TAG"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Start Harper with Flair component (for E2E / Playwright)
run: |
docker run -d \
--name harper-flair \
-p 9925:9925 -p 9926:9926 \
-v ${{ github.workspace }}:/flair \
-e ROOTPATH=/tmp/harper-flair \
-e HDB_ADMIN_USERNAME=admin \
-e HDB_ADMIN_PASSWORD=admin123 \
-e NODE_HOSTNAME=0.0.0.0 \
-e OPERATIONSAPI_NETWORK_PORT=9925 \
-e HTTP_PORT=9926 \
--entrypoint /bin/sh \
harperfast/harper:${{ steps.harper-image.outputs.tag }} \
-c "harper install && harper dev /flair"
# Wait for Harper to be healthy (Basic auth required by Harper's authorizeLocal)
for i in $(seq 1 60); do
if curl -sf -u admin:admin123 http://localhost:9926/Health 2>/dev/null; then
echo "Harper ready (${i}s)"
break
fi
if [ "$i" -eq 60 ]; then
echo "Harper failed to start after 120s. Full logs:"
docker logs harper-flair 2>&1
echo "---"
echo "Trying direct curl with verbose:"
curl -v http://localhost:9926/Health 2>&1 || true
exit 1
fi
sleep 2
done
- name: E2E CLI smoke test
run: bash test/e2e-cli.sh
env:
FLAIR_PORT: "9926"
FLAIR_ADMIN_PASS: admin123
- name: Install Playwright browsers
run: bunx playwright install --with-deps chromium
- name: Playwright E2E tests
run: bunx playwright test
env:
FLAIR_URL: http://localhost:9926
FLAIR_ADMIN_PASS: admin123
pack-smoke:
# Matrixed (#672), not just pinned to the primary version: this
# job is the closest thing in this pipeline to "a real user runs the
# published flair CLI" — install the packed tarball, then every step below
# runs `node .../dist/cli.js` directly (no Bun in the loop). That's exactly
# the path the motivating incident hit (flair running in production on a
# Node major CI never exercised), it's not network/download-heavy like
# test-integration (no Docker image, no HuggingFace model pull, no
# Playwright), and its own timeout budget (15 min) has headroom — so the
# 3x cost buys real, high-signal coverage rather than padding the matrix
# for its own sake.
name: "Install-from-tarball smoke (node ${{ matrix.node-version }})"
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
node-version: ["22", "24", "26"]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2
with:
bun-version: "1.3.10"
# Node 22+ is required by flair's engines; ubuntu-latest ships Node 20.
# The CLI spawns Harper via process.execPath, so we need real node (not
# bun) on PATH for that to resolve correctly.
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ matrix.node-version }}
- uses: socketdev/action@ba6de6cc0565af1f42295590380973573297e31f # v1.3.2
with:
mode: firewall-free
- run: sfw bun install --frozen-lockfile
- run: bun run build && bun run build:cli
- name: Verify dist/ modules import cleanly (dev surface)
run: node scripts/check-imports.mjs dist
- name: Pack
id: pack
run: |
npm pack
echo "TGZ_PATH=$(pwd)/$(ls tpsdev-ai-flair-*.tgz)" >> $GITHUB_OUTPUT
- name: Install-from-tarball + daemon lifecycle round-trip
run: |
set -euo pipefail
TMPDIR=$(mktemp -d)
cd "$TMPDIR"
npm init -y > /dev/null
npm install "${{ steps.pack.outputs.TGZ_PATH }}"
# Native embedding binary — flair declares mac-arm64 as optional; on linux
# CI we install the matching variant so Harper's embeddings component can
# resolve it when started by `flair init`.
npm install --no-save @node-llama-cpp/linux-x64@3
# Packaging surface guard (0.5.3 regression): every *.js in the INSTALLED
# tarball must import without ERR_MODULE_NOT_FOUND. Catches files-manifest
# gaps and cross-boundary imports that don't survive `npm pack`.
node "$GITHUB_WORKSPACE/scripts/check-imports.mjs" node_modules/@tpsdev-ai/flair/dist
FLAIR="node $(pwd)/node_modules/@tpsdev-ai/flair/dist/cli.js"
PORT=19999
export FLAIR_ADMIN_PASS=pack-smoke-admin
# Packaging guard (0.5.3 regression): tarball can execute end-to-end
$FLAIR --version
# Full init: installs Harper + downloads embedding model + starts daemon
# + generates Ed25519 keypair + seeds agent record via ops API
$FLAIR init --agent-id smoke --port $PORT --admin-pass "$FLAIR_ADMIN_PASS" --skip-soul
# Post-init status sanity
STATUS=$($FLAIR status --port $PORT); echo "$STATUS"
echo "$STATUS" | grep -q "running" || { echo "FAIL: not running after init"; exit 1; }
# ── flair#654 HARD GATE: authorizeLocal now defaults to false ──────────
# Proves the bootstrap-ordering invariant K&S required: the admin
# credential (~/.flair/admin-pass / --admin-pass / FLAIR_ADMIN_PASS) is
# established BEFORE any ops-API seed call fires, so seeding never rode
# (and post-flip, never needs) Harper's authorizeLocal loopback forgery.
OPS_PORT=$((PORT - 1))
# 1. NEGATIVE: a credential-less loopback ops-API call must now be
# REJECTED, not auto-authorized as super_user. This is the actual
# hole #654 closes — proves the toggle really is off (not silently
# overridden by a stale config path) rather than just asserting the
# seed above happened to succeed for the wrong reason.
UNAUTH_STATUS=$(curl -s -o /tmp/flair654-unauth.json -w "%{http_code}" -X POST "http://127.0.0.1:${OPS_PORT}/" \
-H "Content-Type: application/json" -d '{"operation":"system_information"}')
echo "Unauthenticated loopback ops-API call → HTTP $UNAUTH_STATUS ($(cat /tmp/flair654-unauth.json))"
if [ "$UNAUTH_STATUS" = "200" ]; then
echo "FAIL: unauthenticated loopback ops-API call returned 200 — authorizeLocal is still forging super_user (flair#654 regression)"
exit 1
fi
# 2. POSITIVE: the same ops API accepts genuine Basic admin auth. The
# agent seed inside `flair init` above already proved this
# implicitly (it would have failed otherwise); assert it directly.
AUTH_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "http://127.0.0.1:${OPS_PORT}/" \
-u "admin:${FLAIR_ADMIN_PASS}" -H "Content-Type: application/json" -d '{"operation":"system_information"}')
echo "Basic-authed loopback ops-API call → HTTP $AUTH_STATUS"
[ "$AUTH_STATUS" = "200" ] || { echo "FAIL: Basic-authed ops-API call did not succeed ($AUTH_STATUS)"; exit 1; }
# 3. FEDERATION SEED: seedFederationInstanceViaOpsApi is only exercised
# by --remote/hub init, never by the plain local init above — so the
# agent seed alone doesn't cover it. Point a second, --remote init AT
# THIS SAME already-running instance (self-federation-style; no real
# Fabric needed) to exercise both seedAgentViaOpsApi (a second agent)
# and seedFederationInstanceViaOpsApi (a FederationInstance row) via
# genuine Basic admin auth against the live, already-credentialed
# daemon from the init above.
INIT_REMOTE_OUT=$($FLAIR init --target "http://127.0.0.1:$PORT" --remote --force \
--admin-pass "$FLAIR_ADMIN_PASS" --agent-id smoke-hub-agent 2>&1)
echo "$INIT_REMOTE_OUT"
echo "$INIT_REMOTE_OUT" | grep -q "registered on remote instance" || {
echo "FAIL: federation-seed init did not report the agent seed succeeding"
exit 1
}
echo "$INIT_REMOTE_OUT" | grep -q "Federation Instance created" || {
echo "FAIL: federation-seed init did not report the FederationInstance seed succeeding"
exit 1
}
# Confirm the FederationInstance row actually landed (not just a 0 exit
# code) — a genuine Basic-authed read-back, independent of the init
# command's own success message.
FEDERATION_CHECK=$(curl -s -o /dev/null -w "%{http_code}" \
-u "admin:${FLAIR_ADMIN_PASS}" "http://127.0.0.1:${PORT}/FederationInstance")
[ "$FEDERATION_CHECK" = "200" ] || {
echo "FAIL: /FederationInstance not reachable with Basic admin auth after federation seed ($FEDERATION_CHECK)"
exit 1
}
# ── end flair#654 hard gate ─────────────────────────────────────────────
# Restart race guard (0.5.4 regression): `flair restart` must block until
# Harper is actually reachable, so a following `flair status` must
# eventually report running, not unreachable.
#
# flair#691: this used to be a SINGLE-SHOT check immediately after
# `flair restart` returned — on a slow CI runner that races the
# daemon's own post-restart boot (deferred migration-boot readiness
# poll, embeddings-settle window, etc.) and flakes on "unreachable"
# even though the restart is genuinely still in flight, not broken.
# Bounded-retry instead (never a fixed `sleep N` guess either) —
# see scripts/ci/migration-lane-lib.sh's wait_for_flair_status_running,
# the same primitive the migration CI lanes use for every
# post-boot/post-restart verification (ci(migrations) PR, #691).
source "$GITHUB_WORKSPACE/scripts/ci/migration-lane-lib.sh"
$FLAIR restart --port $PORT
wait_for_flair_status_running "$FLAIR" "$PORT" 90
# Write/search round-trip (0.5.2-class regression guard): write a
# memory as the smoke agent, then search for it. Exercises the
# authenticated PUT path, agent-scoped keyword fallback in
# SemanticSearch, and the full embedding pipeline if it's ready.
# `memory add` / `memory search` use the `api()` helper, which
# resolves the base URL from FLAIR_URL — set it to our custom port.
export FLAIR_URL=http://127.0.0.1:$PORT
$FLAIR memory add --agent smoke --content "pack-smoke-roundtrip-marker-unique"
SEARCH=$($FLAIR memory search --agent smoke --q "pack-smoke-roundtrip-marker-unique")
echo "$SEARCH"
echo "$SEARCH" | grep -q "pack-smoke-roundtrip-marker-unique" || {
echo "FAIL: memory write/search round-trip did not return the marker"
exit 1
}
# `flair test` exercises PUT /Memory + POST /SemanticSearch + DELETE
# against the running daemon. Guards against the 0.5.6-era regression
# where `flair test` POSTed to /Memory and always failed.
$FLAIR test --agent smoke
# Deploy dry-run (FLAIR-DEPLOY guard): `flair deploy --dry-run` must
# resolve the installed package and validate args against a Fabric
# target. Doesn't call Fabric. Catches drift in the published `files`
# array vs. what deploy packages.
$FLAIR deploy --dry-run \
--fabric-org pack-smoke-org \
--fabric-cluster pack-smoke-cluster \
--fabric-user admin \
--fabric-password smoke-pw
# Tear down
$FLAIR stop --port $PORT || true
upgrade-smoke:
name: Upgrade from npm-stable
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2
with:
bun-version: "1.3.10"
# Pinned, not matrixed (#672): this job's invariant is
# data/schema survival across a flair VERSION upgrade (baseline npm-latest
# → HEAD), which is orthogonal to which Node major runs it — matrixing
# would 3x an already-heavy job (full pack + two `flair init` lifecycles +
# reembed) for no extra signal on the thing it actually tests. Pinned to
# 26 (Current) rather than the old 22 default so at least one non-matrixed
# job runs on the newest supported runtime; Node-major breadth itself is
# covered by pack-smoke (22/24/26) and test-unit/typecheck above.
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "26"
- uses: socketdev/action@ba6de6cc0565af1f42295590380973573297e31f # v1.3.2
with:
mode: firewall-free
- run: sfw bun install --frozen-lockfile
- run: bun run build && bun run build:cli
- name: Pack HEAD
id: pack
run: |
npm pack
echo "TGZ_PATH=$(pwd)/$(ls tpsdev-ai-flair-*.tgz)" >> $GITHUB_OUTPUT
# Pre-downloaded and pointed at by FLAIR_MODELS_DIR (below) for BOTH the
# baseline and HEAD boots — ci(migrations) extension (#504/#695
# enforcement): keeps `getModelId()`'s embeddingModel stamp identical
# across the swap, so embedding-stamp (always-on, runs before the
# synthetic migration in registry order) doesn't contend with the
# migration-assertion seeding added below. Same rationale as the new
# migration-ci-lanes.yml downgrade-and-revert job.
- name: Pre-download embedding model (shared by baseline + HEAD)
# Resolves cache → first-party GitHub release mirror → HuggingFace
# (last), with sha256 verification regardless of source (flair#715).
run: bash "$GITHUB_WORKSPACE/scripts/ci/fetch-model.sh" nomic-embed-text-v1.5.Q4_K_M.gguf "$GITHUB_WORKSPACE/models"
- name: Install npm-stable, seed data, upgrade, verify
run: |
set -euo pipefail
source "$GITHUB_WORKSPACE/scripts/ci/migration-lane-lib.sh"
export FLAIR_MODELS_DIR="$GITHUB_WORKSPACE/models"
BASE_DIR=$(mktemp -d)
cd "$BASE_DIR"
npm init -y > /dev/null
# 1. Install current npm-stable
npm install @tpsdev-ai/flair@latest
npm install --no-save @node-llama-cpp/linux-x64@3
BASELINE=$(node -p "require('@tpsdev-ai/flair/package.json').version")
HEAD_VERSION=$(node -p "require('$GITHUB_WORKSPACE/package.json').version")
echo "Baseline: $BASELINE — HEAD: $HEAD_VERSION"
# flair#620: this used to skip the entire upgrade-verify path whenever
# $BASELINE == $HEAD_VERSION (true on every ordinary feature PR that
# hasn't bumped package.json — the vast majority of PRs), so the real
# cross-version data-survival path only ever ran at release-bump PRs.
# That's backwards: the code under test on a feature PR differs from
# published-latest regardless of whether the *semver string* moved —
# a feature PR can still change the daemon start/stop sequence, the
# reembed migration, storage schema, etc. The invariant we care about
# (HEAD's binary must start cleanly against baseline's on-disk data
# and not lose or corrupt memories) has nothing to do with whether
# someone remembered to bump the version. So: no early exit — always
# install published-latest as baseline, pack+install HEAD's actual
# built tarball as the upgrade target, and run the full sequence.
# The one no-op case left (baseline tag == this exact HEAD commit,
# e.g. a re-run against an unchanged tree) still exercises install +
# start + reembed + round-trip; it just isn't testing a *content*
# diff, and that's an acceptable, rare edge rather than the default.
if [ "$BASELINE" = "$HEAD_VERSION" ]; then
echo "Note: BASELINE version string ($BASELINE) == HEAD package.json version. This PR hasn't bumped semver, but HEAD's packed tarball is still this PR's actual code — running the full upgrade-verify path against it, not skipping."
fi
BASE_FLAIR="node $BASE_DIR/node_modules/@tpsdev-ai/flair/dist/cli.js"
PORT=19998
export FLAIR_ADMIN_PASS=upgrade-smoke-admin
export FLAIR_URL=http://127.0.0.1:$PORT
# 2. Init at baseline + write marker memory
$BASE_FLAIR init --agent-id upgrade --port $PORT --admin-pass "$FLAIR_ADMIN_PASS" --skip-soul
$BASE_FLAIR memory add --agent upgrade --content "upgrade-smoke-pre-marker"
PRE=$($BASE_FLAIR memory search --agent upgrade --q "upgrade-smoke-pre-marker")
echo "$PRE" | grep -q "upgrade-smoke-pre-marker" || {
echo "FAIL: baseline write/search didn't work — setup broken, not an upgrade regression"
exit 1
}
# 2a. ci(migrations) extension (#504/#695 enforcement arm — see
# flair#695): seed STUB-STAMPED rows via the
# version-stable ops-API bulk insert (ops_seed_stub_memory_rows —
# see its doc in migration-lane-lib.sh for why a CLI/REST seed
# can't be trusted against the published baseline: its api() auth
# chain differs from HEAD's and 401s for a keyless agent). The
# deliberately-stale 'upgrade-lane-stub-stamp' (and NO
# FLAIR_EMBEDDING_MODEL pinning in this job — the opposite choice
# from the downgrade lane, on purpose) means the always-on
# embedding-stamp migration has REAL pending work on the HEAD
# boot: it re-embeds these rows with genuine vectors via its
# production regen path, and the synthetic migration then stamps
# them — so the recall-parity search below exercises real
# embeddings, not stubs. `flair reembed` (step 4b) already proves
# the OLD manual re-embed path; this proves the NEW boot-keyed
# runner actually ran, independent of it.
MIGRATION_SEED_COUNT=20
BASE_OPS_PORT=$((PORT - 1))
ops_seed_stub_memory_rows "http://127.0.0.1:${BASE_OPS_PORT}" "$FLAIR_ADMIN_PASS" "$RESERVED_TEST_AGENT_ID" \
"upgrade-lane-migration-seed" "upgrade-lane-migration-seed-row" "$MIGRATION_SEED_COUNT" "upgrade-lane-stub-stamp"
echo "Seeded ${MIGRATION_SEED_COUNT} stub-stamped rows as ${RESERVED_TEST_AGENT_ID} for the upgrade lane's migration assertions."
# 3. Stop baseline. Install HEAD into a FRESH project dir rather than
# reifying the tarball on top of baseline's node_modules in place.
#
# `flair stop` (cli.ts) sends SIGTERM and returns immediately — it
# does not wait for the Harper child process to actually exit. The
# very next command used to be `npm install <tarball>` run in the
# SAME directory the just-killed process was serving out of, which
# overwrites an existing registry-resolved @tpsdev-ai/flair with a
# same-named local-tarball-resolved one. That combination (possible
# file contention from the not-yet-exited process, plus npm's
# arborist reifying a same-name registry→tarball swap in place) can
# throw during reify's commit phase. npm's own rollback path for a
# failed reify has a long-standing upstream bug (npm/cli#4907:
# `rollbackMoveBackRetiredUnchanged` in @npmcli/arborist) that
# itself crashes with `ERR_INVALID_ARG_TYPE: The "from" argument
# must be of type string` — masking whatever the real error was.
# This hit flair#611 deterministically across two separate CI runs
# (2026-07-07): baseline install, HEAD tarball install, Ed25519
# auth, and semantic search all verified clean beforehand, so this
# was never a 0.21.0 content regression — it's an npm-internals /
# process-teardown race in the test's own in-place-overwrite
# choreography, exercised for the first time here because prior
# release PRs were same-version (BASELINE == HEAD) no-ops.
#
# Fix: install HEAD fresh, in its own directory, instead of
# overwriting baseline's tree. Flair's data lives in ~/.flair
# (home-dir scoped, independent of node_modules location), so this
# still exercises the real invariant under test — HEAD starting
# against baseline's on-disk data — without ever asking npm to
# reconcile/rename an existing differently-sourced package or race
# a still-terminating daemon.
$BASE_FLAIR stop --port $PORT || true
HEAD_DIR=$(mktemp -d)
cd "$HEAD_DIR"
npm init -y > /dev/null
npm install "${{ steps.pack.outputs.TGZ_PATH }}"
npm install --no-save @node-llama-cpp/linux-x64@3
FLAIR="node $HEAD_DIR/node_modules/@tpsdev-ai/flair/dist/cli.js"
NEW=$(node -p "require('@tpsdev-ai/flair/package.json').version")
[ "$NEW" = "$HEAD_VERSION" ] || { echo "FAIL: expected $HEAD_VERSION in node_modules, got $NEW"; exit 1; }
echo "Upgraded $BASELINE → $NEW"
# 4. Start HEAD against baseline's data dir (~/.flair by default).
# FLAIR_ENABLE_TEST_MIGRATIONS=1 registers the CI-only synthetic
# migration (never ships — registration is conditional on this
# exact-match env var, see resources/migrations/synthetic-test-migration.ts)
# so this boot has the stub-stamped rows seeded in 2a above as real
# pending work.
export FLAIR_ENABLE_TEST_MIGRATIONS=1
$FLAIR start --port $PORT
# Bounded-retry, not a single-shot check (flair#691's lesson,
# generalized — see scripts/ci/migration-lane-lib.sh).
wait_for_flair_status_running "$FLAIR" "$PORT" 90
# 4a. ci(migrations) extension — the zero-touch boot-keyed
# auto-migration assertions this lane exists to enforce
# (flair#695 / flair#695):
# (a) the auto-migration actually RAN (not just "boot didn't crash")
# (b) its content-hash envelope matched (invariant IV's completion gate)
# (c) recall parity — the migration-touched ground truth is still
# retrievable by search post-migration, not just present in storage
# Deliberately BEFORE the manual `flair reembed` below (4b): the
# always-on embedding-stamp migration (runs ahead of the synthetic
# one in registry order) already gives these stub-stamped rows real
# embeddings as part of THIS SAME auto-migration cycle — waiting for
# the cycle to finish here, before reembed's own writes touch the
# same rows, avoids two independent writers racing on them.
BASE_URL="http://127.0.0.1:${PORT}"
OPS_URL="http://127.0.0.1:$((PORT - 1))"
wait_for_migration_state "$BASE_URL" "$FLAIR_ADMIN_PASS" "$SYNTHETIC_MIGRATION_ID" "completed" 120
echo "Ledger content-hash envelope for ${SYNTHETIC_MIGRATION_ID}:"
check_ledger_hash_envelope "$OPS_URL" "$FLAIR_ADMIN_PASS" "$SYNTHETIC_MIGRATION_ID" || {
echo "FAIL: content-hash envelope did not match=true after the upgrade-lane migration run"
exit 1
}
MIGRATION_RECALL=$($FLAIR memory search --agent "$RESERVED_TEST_AGENT_ID" --q "upgrade-lane-migration-seed-row-0")
echo "$MIGRATION_RECALL" | grep -q "upgrade-lane-migration-seed-row-0" || {
echo "FAIL: migration-seeded ground truth not retrievable by search post-migration (recall parity broken)"
exit 1
}
echo "Migration ran, envelope matched, migration-seeded ground truth recalls correctly."
# 4b. Run the documented post-upgrade migration: re-embed all memories
# so their stored vectors match the running Harper version's expected
# shape. Required when bumping @harperfast/harper across versions where
# HNSW storage internals changed (e.g. 5.0.1 → 5.0.9). See CHANGELOG.
$FLAIR reembed --port $PORT
# 5. Pre-upgrade memory must still be readable (the core regression guard)
POST=$($FLAIR memory search --agent upgrade --q "upgrade-smoke-pre-marker")
echo "$POST"
echo "$POST" | grep -q "upgrade-smoke-pre-marker" || {
echo "FAIL: pre-upgrade memory lost upgrading $BASELINE → $NEW"
exit 1
}
# 6. New writes must work post-upgrade (schema didn't silently break writes)
$FLAIR memory add --agent upgrade --content "upgrade-smoke-post-marker"
POST_NEW=$($FLAIR memory search --agent upgrade --q "upgrade-smoke-post-marker")
echo "$POST_NEW" | grep -q "upgrade-smoke-post-marker" || {
echo "FAIL: post-upgrade write didn't round-trip"
exit 1
}
# Teardown
$FLAIR stop --port $PORT || true
typecheck:
# Matrixed name — see test-unit's comment above for why (GHA suffixes
# matrixed job names/contexts; the "Type Check" required check is
# re-emitted by the typecheck-gate job below instead).
#
# Honesty note (#672): as of this PR, `bunx tsc` below runs
# entirely under Bun's own runtime — this job has no other step that
# shells out to `node` — so today the matrix only changes which Node major
# is on PATH without anything actually consuming it; it is not yet a
# source of NEW signal the way test-unit's `node scripts/*.mjs` checks or
# pack-smoke's full CLI lifecycle are. Added anyway for (a) consistency
# with "core test jobs" per the CI matrix ask, and (b) future-proofing: a
# future type-check step that shells to `node` (or a `@types/node`
# resolution quirk) is then already covered without another CI PR. If that
# tradeoff isn't wanted, dropping the matrix here (keep node-version
# unpinned/removed entirely, since nothing needs it) is a reasonable
# follow-up — flagging for reviewer call rather than silently deciding.
name: "Type Check (node ${{ matrix.node-version }})"
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: ["22", "24", "26"]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2
with:
bun-version: "1.3.10"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ matrix.node-version }}
- uses: socketdev/action@ba6de6cc0565af1f42295590380973573297e31f # v1.3.2
with:
mode: firewall-free
- run: sfw bun install --frozen-lockfile
- run: bunx tsc --noEmit -p tsconfig.check.json
# Required-status-check adapter — see test-unit-gate's comment above.
typecheck-gate:
name: Type Check
needs: typecheck
runs-on: ubuntu-latest
if: always()
steps:
- name: Require all Node-version legs to pass
run: |
echo "typecheck matrix result: ${{ needs.typecheck.result }}"
if [ "${{ needs.typecheck.result }}" != "success" ]; then
echo "::error::One or more Type Check Node-version legs did not succeed"
exit 1
fi
typecheck-strict:
name: "Type Check (strict)"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2
with:
bun-version: "1.3.10"
- uses: socketdev/action@ba6de6cc0565af1f42295590380973573297e31f # v1.3.2
with:
mode: firewall-free
- run: sfw bun install --frozen-lockfile
# Root resources (tsconfig.check.json has @harperfast/harper paths override
# so harper's raw .ts internals don't pollute the output)
- name: Type check root (resources)
run: npx tsc --noEmit -p tsconfig.check.json
# Root src/ (CLI, probe, fleet-verify, deploy, bridges, etc. — everything
# under src/ EXCEPT cli.ts, which stays on tsconfig.cli.json's strict:false
# until #622 splits the monolith). Closes #643: src/** previously had zero
# strict coverage — tsconfig.check.json only ever covered resources/**, so a
# module like src/probe.ts or src/fleet-verify.ts could regress without CI
# noticing. Verified strict-clean with zero errors before wiring this in.
- name: Type check root src (strict, excl. cli.ts — #622)
run: npx tsc --noEmit -p tsconfig.check.src.json
# Root CLI entry point (tsconfig.cli.json — strict: false, matches build:cli)
- name: Type check root CLI
run: npx tsc --noEmit -p tsconfig.cli.json
# Build flair-client so workspace consumers (n8n-nodes-flair, pi-flair,
# openclaw-flair) can resolve @tpsdev-ai/flair-client emitted types.
# Without this, workspace tsc fails with "Cannot find module" because
# Bun workspace symlinks point at source, not emitted .d.ts files.
- name: Build flair-client
run: cd packages/flair-client && bun run build
# Workspace packages: check every package that has a tsconfig.json.
# Collect all failures so the full picture is visible, then hard-fail
# if any package had type errors.
- name: Type check workspace packages
run: |
FAIL=0
for pkg in packages/*/; do
if [ -f "$pkg/tsconfig.json" ]; then
echo "::group::Type check: $pkg"
(cd "$pkg" && npx tsc --noEmit) || FAIL=1
echo "::endgroup::"
else
echo "Skipping $pkg (no tsconfig.json)"
fi
done
if [ "$FAIL" -ne 0 ]; then
echo "::error::One or more workspace packages have type errors"
exit 1
fi
audit:
name: Dependency Audit
runs-on: ubuntu-latest
# This job was the ONLY one in this file without a job timeout, so when the
# Socket firewall (`sfw`) install below hung (ops-fumh, seen 2026-07-04) it
# ran 33+ min with nothing to cancel it, indefinitely blocking merge on a
# required check. A job timeout makes a hung sfw install fail-fast and
# re-runnable instead of hanging. `sfw bun install --frozen-lockfile` with
# no dep changes is normally seconds; 10 min is generous headroom.
timeout-minutes: 10
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2
with:
bun-version: "1.3.10"
- uses: socketdev/action@ba6de6cc0565af1f42295590380973573297e31f # v1.3.2
with:
mode: firewall-free
- name: sfw bun install (frozen — 5 min guard against sfw hangs, ops-fumh)
timeout-minutes: 5
run: sfw bun install --frozen-lockfile
- name: Run audit (advisory-only for transitive deps)
run: bun audit || echo "::warning::Audit found vulnerabilities — all in harper transitive deps (unreleased v5 build)"
continue-on-error: true
sast-semgrep:
name: Semgrep SAST
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- run: python3 -m pip install semgrep
# .github/renovate.json excluded: its minimumReleaseAge:"0 days" entries are documented,
# intentional exceptions (keep-current allow-list + vulnerabilityAlerts security fast-track)
# that a new Semgrep (v42) rule flags as false positives. See docs/supply-chain-policy.md.
# Renovate config is reviewed via K&S, not SAST.
- run: >
semgrep scan --config=auto --error
--exclude='**/test/**' --exclude='**/dist/**' --exclude='.github/renovate.json'
sast-codeql:
name: CodeQL SAST
runs-on: ubuntu-latest
permissions:
security-events: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Initialize CodeQL
uses: github/codeql-action/init@480db559a14342288b67e54bd959dd52dc3ee68f # v3
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-config.yml
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@480db559a14342288b67e54bd959dd52dc3ee68f # v3
doclint:
name: "Doc/Code Lint: implementation-term leaks"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2
with:
bun-version: "1.3.10"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- uses: socketdev/action@ba6de6cc0565af1f42295590380973573297e31f # v1.3.2
with:
mode: firewall-free
- run: sfw bun install --frozen-lockfile
# Build the publishable packages so check-impl-term-leaks.sh scans the
# SAME packages/*/dist/ surface that ships to npm — and that the release
# build scans. Without this, a bead-ref/internal label in a package's
# SOURCE comment (which tsc compiles verbatim into dist/) is invisible at
# PR time and only fails at release. This is exactly what blocked v0.15.0:
# a coordination-write-surface comment in packages/flair-mcp/src/index.ts
# carried an internal ref into dist/index.js, caught only by the
# release-time check (#528). Building here moves that catch to the PR.
- name: Build publishable packages
run: |
for pkg in flair-client flair-mcp langgraph-flair n8n-nodes-flair openclaw-flair pi-flair flair-bench; do
echo "::group::build $pkg"
(cd "packages/$pkg" && bun run build)
echo "::endgroup::"
done
- run: chmod +x ./scripts/check-impl-term-leaks.sh && ./scripts/check-impl-term-leaks.sh
docs-freshness:
name: "Doc/Code Lint: docs-freshness gate"
runs-on: ubuntu-latest
steps:
# fetch-depth: 0 pulls full history + tags so the CHANGELOG check can count
# feat/fix commits since the last v* tag. The other checks are file-only.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2
with:
bun-version: "1.3.10"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- uses: socketdev/action@ba6de6cc0565af1f42295590380973573297e31f # v1.3.2
with:
mode: firewall-free
- run: sfw bun install --frozen-lockfile
# Build the CLI so the command-description check can introspect the real
# command tree from dist/cli.js (the exported `program`), not a source regex.
- name: Build CLI
run: bun run build:cli
- run: node scripts/docs-freshness-check.mjs