forge-workflow 0.1.0-beta.7 → 0.1.0-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,654 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ paths:
7
+ - 'bin/**'
8
+ - 'lib/**'
9
+ - 'scripts/**'
10
+ - 'test/**'
11
+ - 'test-env/**'
12
+ - 'skills/**'
13
+ - 'packages/**'
14
+ - 'package.json'
15
+ - 'bunfig.toml'
16
+ - '.github/workflows/**'
17
+ - '.github/agentic-workflows/**'
18
+ pull_request:
19
+ paths:
20
+ - 'bin/**'
21
+ - 'lib/**'
22
+ - 'scripts/**'
23
+ - 'test/**'
24
+ - 'test-env/**'
25
+ - 'skills/**'
26
+ - 'packages/**'
27
+ - 'package.json'
28
+ - 'bunfig.toml'
29
+ - '.github/workflows/**'
30
+ - '.github/agentic-workflows/**'
31
+ merge_group:
32
+ workflow_dispatch:
33
+ schedule:
34
+ - cron: '0 3 * * 0'
35
+
36
+ permissions:
37
+ contents: read
38
+
39
+ concurrency:
40
+ group: tests-${{ github.event.pull_request.number || github.ref }}
41
+ cancel-in-progress: true
42
+
43
+ jobs:
44
+ changes:
45
+ # Classifies the PR diff so the expensive 3-OS x 2-Node Full Matrix only runs
46
+ # when the change can plausibly behave differently per operating system.
47
+ # Anything that is not a pull_request (push to master, merge_group, schedule,
48
+ # workflow_dispatch) is always treated as OS-sensitive.
49
+ name: Classify Changed Paths
50
+ runs-on: ubuntu-latest
51
+ timeout-minutes: 10
52
+ outputs:
53
+ os_sensitive: ${{ steps.filter.outputs.os_sensitive }}
54
+ steps:
55
+ - name: Checkout code
56
+ uses: actions/checkout@v7
57
+ with:
58
+ fetch-depth: 0
59
+ - name: Classify diff
60
+ id: filter
61
+ shell: bash
62
+ env:
63
+ EVENT_NAME: ${{ github.event_name }}
64
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
65
+ HEAD_SHA: ${{ github.sha }}
66
+ run: |
67
+ set -uo pipefail
68
+ if [ "$EVENT_NAME" != "pull_request" ]; then
69
+ echo "Non-PR event ($EVENT_NAME): full matrix always runs."
70
+ echo "os_sensitive=true" >> "$GITHUB_OUTPUT"
71
+ exit 0
72
+ fi
73
+ if ! changed="$(git diff --name-only "$BASE_SHA...$HEAD_SHA")"; then
74
+ echo "::warning::Could not resolve the PR diff; failing safe to the full matrix."
75
+ echo "os_sensitive=true" >> "$GITHUB_OUTPUT"
76
+ exit 0
77
+ fi
78
+ # Surfaces where Windows/macOS genuinely diverge from Linux:
79
+ # process/CLI entry points, native + packaged deps, filesystem/path/symlink
80
+ # helpers, git-hook + installer plumbing, the SQLite kernel (Windows file
81
+ # handle/EBUSY history), and this workflow itself.
82
+ os_sensitive_pattern='^(bin/|packages/|test-env/|plugin/hooks/|lib/core/|lib/kernel/|lib/activation/|lib/preflight/|lib/commands/(worktree|setup|init|install)|lib/(shell-utils|symlink-utils|file-utils|file-hash|package-root|forge-lock|detect-worktree|protected-path-manifest|hook-global-installer|hook-renderer|lefthook-check|lefthook-wiring|husky-migration|setup|setup-utils|reset)\.js$|install\.sh$|scripts/install\.|lefthook\.yml$|bunfig\.toml$|package\.json$|bun\.lock$|\.github/workflows/test\.yml$)'
83
+ echo "Changed files:"
84
+ printf '%s\n' "$changed"
85
+ if printf '%s\n' "$changed" | grep -Eq "$os_sensitive_pattern"; then
86
+ echo "OS-sensitive paths changed: running the full matrix."
87
+ echo "os_sensitive=true" >> "$GITHUB_OUTPUT"
88
+ else
89
+ echo "No OS-sensitive paths changed: full matrix skipped on this PR."
90
+ echo "Cross-OS coverage on this PR still comes from Windows Smoke, macOS Smoke"
91
+ echo "and the windows-node22 Targeted PR Tests lane; the full matrix re-runs on"
92
+ echo "merge_group, on every push to master, and on the weekly schedule."
93
+ echo "os_sensitive=false" >> "$GITHUB_OUTPUT"
94
+ fi
95
+
96
+ full-matrix:
97
+ name: Full Matrix (${{ matrix.os }} / Node ${{ matrix.node-version }})
98
+ needs: [changes]
99
+ if: ${{ needs.changes.outputs.os_sensitive == 'true' }}
100
+ runs-on: ${{ matrix.os }}
101
+ timeout-minutes: 30
102
+ strategy:
103
+ fail-fast: false
104
+ matrix:
105
+ os: [ubuntu-latest, macos-latest, windows-latest]
106
+ node-version: [22, 24]
107
+ steps:
108
+ - name: Checkout code
109
+ uses: actions/checkout@v7
110
+ - name: Setup Node.js ${{ matrix.node-version }}
111
+ uses: actions/setup-node@v7
112
+ with:
113
+ node-version: ${{ matrix.node-version }}
114
+ - name: Setup Bun
115
+ uses: oven-sh/setup-bun@v2
116
+ with:
117
+ bun-version: __BUN_VERSION__
118
+ - name: Install dependencies
119
+ run: bun install
120
+ - name: Ensure test-results directory
121
+ run: |
122
+ node -e "require('node:fs').mkdirSync('test-results', { recursive: true })"
123
+ - name: Setup test fixtures (Unix)
124
+ if: runner.os != 'Windows'
125
+ run: bash test-env/automation/setup-fixtures.sh --force
126
+ - name: Setup test fixtures (Windows)
127
+ if: runner.os == 'Windows'
128
+ shell: bash
129
+ run: bash test-env/automation/setup-fixtures.sh --force
130
+ - name: Run matrix full suite
131
+ run: node scripts/test-full-suite.js --timeout 15000 --label-prefix full-matrix-${{ matrix.os }}-node${{ matrix.node-version }}
132
+ - name: Upload matrix artifacts
133
+ uses: actions/upload-artifact@v7
134
+ if: always()
135
+ continue-on-error: true
136
+ with:
137
+ name: test-artifacts-full-matrix-${{ matrix.os }}-node${{ matrix.node-version }}
138
+ path: test-results/
139
+ retention-days: 7
140
+
141
+ unit-shard:
142
+ name: Unit Shard ${{ matrix.shard-index }}
143
+ if: github.event_name == 'pull_request'
144
+ runs-on: ubuntu-latest
145
+ timeout-minutes: 20
146
+ strategy:
147
+ fail-fast: false
148
+ max-parallel: 4
149
+ matrix:
150
+ shard-index: [0, 1, 2, 3]
151
+ steps:
152
+ - name: Checkout code
153
+ uses: actions/checkout@v7
154
+ - name: Setup Node.js 24
155
+ uses: actions/setup-node@v7
156
+ with:
157
+ node-version: 24
158
+ - name: Setup Bun
159
+ uses: oven-sh/setup-bun@v2
160
+ with:
161
+ bun-version: __BUN_VERSION__
162
+ - name: Install dependencies
163
+ run: bun install
164
+ - name: Ensure test-results directory
165
+ run: |
166
+ node -e "require('node:fs').mkdirSync('test-results', { recursive: true })"
167
+ - name: Setup test fixtures
168
+ run: bash test-env/automation/setup-fixtures.sh --force
169
+ - name: Run unit shard
170
+ run: bun run test:ci:shard -- --mode shard --shard-index ${{ matrix.shard-index }} --shard-total 4 --label unit-shard-${{ matrix.shard-index }}
171
+ - name: Build unit shard profile
172
+ run: node scripts/test-profile.js --input-dir test-results --output test-results/unit-shard-${{ matrix.shard-index }}.profile.json --label unit-shard-${{ matrix.shard-index }} --integration-skipped true
173
+ - name: Publish unit shard summary
174
+ if: always()
175
+ shell: bash
176
+ run: |
177
+ dashboard_json="$(node scripts/test-dashboard.js --json --profiles-dir test-results)"
178
+ node -e "const d = JSON.parse(process.argv[1]); const lines = ['## Unit Shard Summary', '', 'Suite duration: ' + d.suiteDurationMs + 'ms', 'Integration skipped: ' + (d.integrationSkipped ? 'yes' : 'no')]; if (d.slowestFiles.length) { lines.push('', 'Slowest files:'); for (const entry of d.slowestFiles.slice(0, 5)) lines.push('- ' + entry.file + ': ' + entry.durationMs + 'ms'); } console.log(lines.join('\n'));" "$dashboard_json" >> "$GITHUB_STEP_SUMMARY"
179
+ - name: Upload unit shard artifacts
180
+ uses: actions/upload-artifact@v7
181
+ if: always()
182
+ continue-on-error: true
183
+ with:
184
+ name: test-artifacts-unit-shard-${{ matrix.shard-index }}
185
+ path: test-results/
186
+ retention-days: 7
187
+
188
+ windows-smoke:
189
+ name: Windows Smoke
190
+ if: github.event_name == 'pull_request'
191
+ runs-on: windows-latest
192
+ timeout-minutes: 25
193
+ steps:
194
+ - name: Checkout code
195
+ uses: actions/checkout@v7
196
+ - name: Setup Node.js 24
197
+ uses: actions/setup-node@v7
198
+ with:
199
+ node-version: 24
200
+ - name: Setup Bun
201
+ uses: oven-sh/setup-bun@v2
202
+ with:
203
+ bun-version: __BUN_VERSION__
204
+ - name: Install dependencies
205
+ run: bun install
206
+ - name: Ensure test-results directory
207
+ run: |
208
+ node -e "require('node:fs').mkdirSync('test-results', { recursive: true })"
209
+ - name: Run Windows smoke tests
210
+ run: bun run test:ci:shard -- --mode smoke --label windows-smoke
211
+ - name: Build Windows smoke profile
212
+ run: node scripts/test-profile.js --input-dir test-results --output test-results/windows-smoke.profile.json --label windows-smoke --integration-skipped true
213
+ - name: Publish Windows smoke summary
214
+ if: always()
215
+ shell: bash
216
+ run: |
217
+ dashboard_json="$(node scripts/test-dashboard.js --json --profiles-dir test-results)"
218
+ node -e "const d = JSON.parse(process.argv[1]); console.log(['## Windows Smoke Summary', '', 'Suite duration: ' + d.suiteDurationMs + 'ms'].join('\n'));" "$dashboard_json" >> "$GITHUB_STEP_SUMMARY"
219
+ - name: Upload Windows smoke artifacts
220
+ uses: actions/upload-artifact@v7
221
+ if: always()
222
+ continue-on-error: true
223
+ with:
224
+ name: test-artifacts-windows-smoke
225
+ path: test-results/
226
+ retention-days: 7
227
+
228
+ macos-smoke:
229
+ name: macOS Smoke
230
+ if: github.event_name == 'pull_request'
231
+ runs-on: macos-latest
232
+ timeout-minutes: 25
233
+ steps:
234
+ - name: Checkout code
235
+ uses: actions/checkout@v7
236
+ - name: Setup Node.js 24
237
+ uses: actions/setup-node@v7
238
+ with:
239
+ node-version: 24
240
+ - name: Setup Bun
241
+ uses: oven-sh/setup-bun@v2
242
+ with:
243
+ bun-version: __BUN_VERSION__
244
+ - name: Install dependencies
245
+ run: bun install
246
+ - name: Ensure test-results directory
247
+ run: |
248
+ node -e "require('node:fs').mkdirSync('test-results', { recursive: true })"
249
+ - name: Run macOS smoke tests
250
+ run: bun run test:ci:shard -- --mode smoke --label macos-smoke
251
+ - name: Build macOS smoke profile
252
+ run: node scripts/test-profile.js --input-dir test-results --output test-results/macos-smoke.profile.json --label macos-smoke --integration-skipped true
253
+ - name: Publish macOS smoke summary
254
+ if: always()
255
+ shell: bash
256
+ run: |
257
+ dashboard_json="$(node scripts/test-dashboard.js --json --profiles-dir test-results)"
258
+ node -e "const d = JSON.parse(process.argv[1]); console.log(['## macOS Smoke Summary', '', 'Suite duration: ' + d.suiteDurationMs + 'ms'].join('\n'));" "$dashboard_json" >> "$GITHUB_STEP_SUMMARY"
259
+ - name: Upload macOS smoke artifacts
260
+ uses: actions/upload-artifact@v7
261
+ if: always()
262
+ continue-on-error: true
263
+ with:
264
+ name: test-artifacts-macos-smoke
265
+ path: test-results/
266
+ retention-days: 7
267
+
268
+ cross-os-gate:
269
+ # Stable, single required status check that enforces the cross-OS smoke lanes.
270
+ # Runs even when a smoke lane fails (if: always) and fails the PR unless BOTH
271
+ # Windows and macOS smoke succeeded (or were legitimately skipped). This is the
272
+ # context to mark Required in branch protection so a red Windows/macOS run
273
+ # cannot merge. For doc-only PRs the real smoke lanes are path-filtered out and
274
+ # the Cross-OS Gate no-op in required-checks-bypass.yml satisfies the check.
275
+ name: Cross-OS Gate
276
+ if: ${{ always() && github.event_name == 'pull_request' }}
277
+ needs: [windows-smoke, macos-smoke]
278
+ runs-on: ubuntu-latest
279
+ timeout-minutes: 10
280
+ steps:
281
+ - name: Require Windows + macOS smoke to pass
282
+ run: |
283
+ w='${{ needs.windows-smoke.result }}'
284
+ m='${{ needs.macos-smoke.result }}'
285
+ echo "windows-smoke=$w macos-smoke=$m"
286
+ for r in "$w" "$m"; do
287
+ if [ "$r" = "failure" ] || [ "$r" = "cancelled" ]; then
288
+ echo "::error::Cross-OS smoke did not pass (windows=$w macos=$m)"
289
+ exit 1
290
+ fi
291
+ done
292
+ echo "Cross-OS smoke OK (success or skipped on both)."
293
+
294
+ followup-tests:
295
+ name: Targeted PR Tests (${{ matrix.label }})
296
+ if: github.event_name == 'pull_request'
297
+ runs-on: ${{ matrix.os }}
298
+ timeout-minutes: 30
299
+ strategy:
300
+ fail-fast: false
301
+ matrix:
302
+ include:
303
+ - os: ubuntu-latest
304
+ node-version: 24
305
+ label: ubuntu-node24
306
+ - os: windows-latest
307
+ node-version: 22
308
+ label: windows-node22
309
+ steps:
310
+ - name: Checkout code
311
+ uses: actions/checkout@v7
312
+ with:
313
+ fetch-depth: 0
314
+ - name: Setup Node.js ${{ matrix.node-version }}
315
+ uses: actions/setup-node@v7
316
+ with:
317
+ node-version: ${{ matrix.node-version }}
318
+ - name: Setup Bun
319
+ uses: oven-sh/setup-bun@v2
320
+ with:
321
+ bun-version: __BUN_VERSION__
322
+ - name: Install dependencies
323
+ run: bun install
324
+ - name: Ensure test-results directory
325
+ run: |
326
+ node -e "require('node:fs').mkdirSync('test-results', { recursive: true })"
327
+ - name: Setup test fixtures
328
+ run: bash test-env/automation/setup-fixtures.sh --force
329
+ - name: Resolve affected test targets
330
+ id: affected
331
+ shell: bash
332
+ env:
333
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
334
+ HEAD_SHA: ${{ github.sha }}
335
+ run: |
336
+ node <<'EOF'
337
+ const { execFileSync } = require('node:child_process');
338
+ const { buildTestExecutionPlan } = require('./scripts/test');
339
+
340
+ const baseSha = process.env.BASE_SHA;
341
+ const headSha = process.env.HEAD_SHA;
342
+ const outputPath = process.env.GITHUB_OUTPUT;
343
+ const diffOutput = execFileSync('git', ['diff', '--name-only', `${baseSha}...${headSha}`], {
344
+ encoding: 'utf8',
345
+ });
346
+
347
+ const execStub = (cmd, args, options = {}) => {
348
+ if (cmd === 'git' && args[0] === 'rev-parse' && args.includes('@{upstream}')) {
349
+ return 'origin/pull-request';
350
+ }
351
+ if (cmd === 'git' && args[0] === 'diff') {
352
+ return diffOutput;
353
+ }
354
+ return execFileSync(cmd, args, { ...options, encoding: 'utf8' });
355
+ };
356
+
357
+ const plan = buildTestExecutionPlan(process.cwd(), execStub, { sinceUpstream: true });
358
+ const effectiveMode = plan.mode === 'targeted' && plan.testTargets.length === 0
359
+ ? 'full'
360
+ : plan.mode;
361
+ const lines = [
362
+ `mode=${effectiveMode}`,
363
+ `reason=${plan.reason}`,
364
+ `run_test_env=${plan.runTestEnv}`,
365
+ `run_e2e=${plan.runE2E}`,
366
+ `run_workflow_tests=${plan.runWorkflowTests}`,
367
+ ];
368
+
369
+ if (plan.testTargets.length > 0) {
370
+ lines.push('targets<<EOF');
371
+ lines.push(...plan.testTargets);
372
+ lines.push('EOF');
373
+ }
374
+
375
+ require('node:fs').appendFileSync(outputPath, `${lines.join('\n')}\n`);
376
+ EOF
377
+ - name: Run targeted unit tests
378
+ if: steps.affected.outputs.mode == 'targeted'
379
+ shell: bash
380
+ run: |
381
+ mapfile -t test_targets <<'EOF'
382
+ ${{ steps.affected.outputs.targets }}
383
+ EOF
384
+ bun test --timeout 15000 "${test_targets[@]}" --reporter=junit --reporter-outfile test-results/followup-targeted.xml
385
+ # Route the full-suite case through the same lane-aware, worker-budgeted
386
+ # runner the full matrix uses. A raw `bun test test/` runs every
387
+ # subprocess-heavy file inside one long-lived bun process with no
388
+ # resource-lane separation, which is where the Windows lane's spawns start
389
+ # failing outright.
390
+ - name: Run single-platform unit suite fallback
391
+ if: steps.affected.outputs.mode == 'full'
392
+ run: node scripts/test-full-suite.js --timeout 15000 --label-prefix followup-full-${{ matrix.label }}
393
+ - name: Run affected e2e tests
394
+ if: steps.affected.outputs.run_e2e == 'true'
395
+ run: bun test --timeout 15000 test/e2e/ --reporter=junit --reporter-outfile test-results/followup-e2e.xml
396
+ - name: Run affected edge-case tests
397
+ if: steps.affected.outputs.run_test_env == 'true'
398
+ run: bun test --timeout 15000 test-env/
399
+ - name: Build followup profile
400
+ run: node scripts/test-profile.js --input-dir test-results --output test-results/followup-${{ matrix.label }}.profile.json --label followup-${{ matrix.label }} --integration-skipped true
401
+ - name: Publish followup summary
402
+ if: always()
403
+ shell: bash
404
+ run: |
405
+ dashboard_json="$(node scripts/test-dashboard.js --json --profiles-dir test-results)"
406
+ node -e "const d = JSON.parse(process.argv[1]); const lines = ['## Follow-up Test Summary', '', 'Suite duration: ' + d.suiteDurationMs + 'ms']; if (d.slowestFiles.length) { lines.push('', 'Slowest files:'); for (const entry of d.slowestFiles.slice(0, 5)) lines.push('- ' + entry.file + ': ' + entry.durationMs + 'ms'); } console.log(lines.join('\n'));" "$dashboard_json" >> "$GITHUB_STEP_SUMMARY"
407
+ - name: Upload followup artifacts
408
+ uses: actions/upload-artifact@v7
409
+ if: always()
410
+ continue-on-error: true
411
+ with:
412
+ name: test-artifacts-followup-${{ matrix.label }}
413
+ path: test-results/
414
+ retention-days: 7
415
+
416
+ coverage:
417
+ name: Code Coverage
418
+ runs-on: ubuntu-latest
419
+ timeout-minutes: 30
420
+ steps:
421
+ - name: Checkout code
422
+ uses: actions/checkout@v7
423
+ - name: Setup Node.js 24
424
+ uses: actions/setup-node@v7
425
+ with:
426
+ node-version: 24
427
+ - name: Setup Bun
428
+ uses: oven-sh/setup-bun@v2
429
+ with:
430
+ bun-version: __BUN_VERSION__
431
+ - name: Install dependencies
432
+ run: bun install
433
+ - name: Ensure test-results directory
434
+ run: |
435
+ node -e "require('node:fs').mkdirSync('test-results', { recursive: true })"
436
+ - name: Setup test fixtures
437
+ run: bash test-env/automation/setup-fixtures.sh --force
438
+ - name: Run tests with coverage
439
+ run: bun test --timeout 15000 --coverage test/ --reporter=junit --reporter-outfile test-results/coverage.xml
440
+ - name: Build coverage profile
441
+ run: node scripts/test-profile.js --input-dir test-results --output test-results/coverage.profile.json --label coverage --integration-skipped true
442
+ - name: Publish coverage runtime summary
443
+ if: always()
444
+ shell: bash
445
+ run: |
446
+ dashboard_json="$(node scripts/test-dashboard.js --json --profiles-dir test-results)"
447
+ node -e "const d = JSON.parse(process.argv[1]); const lines = ['## Coverage Runtime Summary', '', 'Suite duration: ' + d.suiteDurationMs + 'ms']; if (d.slowestFiles.length) { lines.push('', 'Slowest files:'); for (const entry of d.slowestFiles.slice(0, 5)) lines.push('- ' + entry.file + ': ' + entry.durationMs + 'ms'); } console.log(lines.join('\n'));" "$dashboard_json" >> "$GITHUB_STEP_SUMMARY"
448
+ - name: Upload coverage artifacts
449
+ uses: actions/upload-artifact@v7
450
+ if: always()
451
+ continue-on-error: true
452
+ with:
453
+ name: test-artifacts-coverage
454
+ path: |
455
+ coverage/
456
+ test-results/
457
+ retention-days: 7
458
+ - name: Coverage summary
459
+ if: always()
460
+ run: |
461
+ {
462
+ echo "## Coverage Report"
463
+ echo ""
464
+ echo "Coverage thresholds: 80% (lines, branches, functions, statements)"
465
+ if [ -f "coverage/coverage-summary.json" ]; then
466
+ echo "Coverage report generated"
467
+ else
468
+ echo "Coverage report not found"
469
+ fi
470
+ } >> "$GITHUB_STEP_SUMMARY"
471
+
472
+ e2e:
473
+ name: E2E Tests
474
+ runs-on: ubuntu-latest
475
+ timeout-minutes: 20
476
+ steps:
477
+ - name: Checkout code
478
+ uses: actions/checkout@v7
479
+ - name: Setup Node.js 24
480
+ uses: actions/setup-node@v7
481
+ with:
482
+ node-version: 24
483
+ - name: Setup Bun
484
+ uses: oven-sh/setup-bun@v2
485
+ with:
486
+ bun-version: __BUN_VERSION__
487
+ - name: Install dependencies
488
+ run: bun install
489
+ - name: Ensure test-results directory
490
+ run: |
491
+ node -e "require('node:fs').mkdirSync('test-results', { recursive: true })"
492
+ - name: Run E2E tests
493
+ run: bun test --timeout 15000 test/e2e/ --reporter=junit --reporter-outfile test-results/e2e.xml
494
+ - name: Build E2E profile
495
+ run: node scripts/test-profile.js --input-dir test-results --output test-results/e2e.profile.json --label e2e --integration-skipped true
496
+ - name: Publish E2E runtime summary
497
+ if: always()
498
+ shell: bash
499
+ run: |
500
+ dashboard_json="$(node scripts/test-dashboard.js --json --profiles-dir test-results)"
501
+ node -e "const d = JSON.parse(process.argv[1]); const lines = ['## E2E Runtime Summary', '', 'Suite duration: ' + d.suiteDurationMs + 'ms']; if (d.slowestFiles.length) { lines.push('', 'Slowest files:'); for (const entry of d.slowestFiles.slice(0, 5)) lines.push('- ' + entry.file + ': ' + entry.durationMs + 'ms'); } console.log(lines.join('\n'));" "$dashboard_json" >> "$GITHUB_STEP_SUMMARY"
502
+ - name: Upload E2E artifacts
503
+ uses: actions/upload-artifact@v7
504
+ if: always()
505
+ continue-on-error: true
506
+ with:
507
+ name: test-artifacts-e2e
508
+ path: test-results/
509
+ retention-days: 7
510
+
511
+ mutation:
512
+ name: Mutation Testing
513
+ runs-on: ubuntu-latest
514
+ timeout-minutes: 60
515
+ if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
516
+ steps:
517
+ - name: Checkout code
518
+ uses: actions/checkout@v7
519
+ - name: Setup Node.js 24
520
+ uses: actions/setup-node@v7
521
+ with:
522
+ node-version: 24
523
+ - name: Setup Bun
524
+ uses: oven-sh/setup-bun@v2
525
+ with:
526
+ bun-version: __BUN_VERSION__
527
+ - name: Install dependencies
528
+ run: bun install
529
+ - name: Setup test fixtures
530
+ run: bash test-env/automation/setup-fixtures.sh --force
531
+ - name: Run mutation testing
532
+ run: npx stryker run
533
+ - name: Upload mutation report
534
+ uses: actions/upload-artifact@v7
535
+ if: always()
536
+ continue-on-error: true
537
+ with:
538
+ name: mutation-report
539
+ path: stryker-report/
540
+ retention-days: 30
541
+
542
+ dashboard-pr:
543
+ name: Test Dashboard (PR)
544
+ if: github.event_name == 'pull_request'
545
+ runs-on: ubuntu-latest
546
+ needs: [unit-shard, windows-smoke, macos-smoke, coverage, e2e]
547
+ timeout-minutes: 15
548
+ steps:
549
+ - name: Checkout code
550
+ uses: actions/checkout@v7
551
+ - name: Download test artifacts
552
+ uses: actions/download-artifact@v8
553
+ with:
554
+ pattern: test-artifacts-*
555
+ path: test-results/downloaded
556
+ merge-multiple: true
557
+ - name: Generate test dashboard
558
+ run: node scripts/test-dashboard.js --profiles-dir test-results/downloaded
559
+ - name: Upload dashboard
560
+ uses: actions/upload-artifact@v7
561
+ continue-on-error: true
562
+ with:
563
+ name: test-dashboard
564
+ path: test-dashboard.json
565
+ retention-days: 7
566
+ - name: Dashboard summary
567
+ if: always()
568
+ run: |
569
+ echo "## Test Quality Dashboard" >> $GITHUB_STEP_SUMMARY
570
+ if [ -f test-dashboard.json ]; then
571
+ cat test-dashboard.json | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Tests: {d[\"testCount\"]}'); print(f'Suite duration: {d[\"suiteDurationMs\"]}ms'); print(f'Integration skipped: {d[\"integrationSkipped\"]}'); print('Slowest files:'); [print(f'- {item[\"file\"]}: {item[\"durationMs\"]}ms') for item in d.get(\"slowestFiles\", [])[:5]]" >> $GITHUB_STEP_SUMMARY 2>/dev/null || echo "Dashboard generated" >> $GITHUB_STEP_SUMMARY
572
+ fi
573
+
574
+ ci-gate:
575
+ # Single aggregate status for the whole Tests workflow. Mark THIS Required in
576
+ # branch protection instead of the individual lanes: it stays green when a lane
577
+ # is legitimately skipped by the path gate above, and red the moment any lane
578
+ # fails or is cancelled. Adding a lane to `needs` is all that is required to
579
+ # bring it under the gate.
580
+ name: CI Gate
581
+ if: ${{ always() }}
582
+ needs:
583
+ - changes
584
+ - full-matrix
585
+ - unit-shard
586
+ - windows-smoke
587
+ - macos-smoke
588
+ - cross-os-gate
589
+ - followup-tests
590
+ - coverage
591
+ - e2e
592
+ runs-on: ubuntu-latest
593
+ timeout-minutes: 10
594
+ steps:
595
+ - name: Aggregate lane results
596
+ shell: bash
597
+ env:
598
+ RESULTS: |
599
+ changes=${{ needs.changes.result }}
600
+ full-matrix=${{ needs.full-matrix.result }}
601
+ unit-shard=${{ needs.unit-shard.result }}
602
+ windows-smoke=${{ needs.windows-smoke.result }}
603
+ macos-smoke=${{ needs.macos-smoke.result }}
604
+ cross-os-gate=${{ needs.cross-os-gate.result }}
605
+ followup-tests=${{ needs.followup-tests.result }}
606
+ coverage=${{ needs.coverage.result }}
607
+ e2e=${{ needs.e2e.result }}
608
+ run: |
609
+ set -uo pipefail
610
+ echo "$RESULTS"
611
+ failed=0
612
+ while IFS='=' read -r lane result; do
613
+ [ -n "$lane" ] || continue
614
+ case "$result" in
615
+ success|skipped) ;;
616
+ *) echo "::error::Lane '$lane' finished as '$result'"; failed=1 ;;
617
+ esac
618
+ done <<< "$RESULTS"
619
+ if [ "$failed" -ne 0 ]; then
620
+ exit 1
621
+ fi
622
+ echo "All test lanes passed or were skipped by the path gate."
623
+
624
+ dashboard-confidence:
625
+ name: Test Dashboard (Confidence Lane)
626
+ if: github.event_name != 'pull_request'
627
+ runs-on: ubuntu-latest
628
+ needs: [full-matrix, coverage, e2e]
629
+ timeout-minutes: 15
630
+ steps:
631
+ - name: Checkout code
632
+ uses: actions/checkout@v7
633
+ - name: Download test artifacts
634
+ uses: actions/download-artifact@v8
635
+ with:
636
+ pattern: test-artifacts-*
637
+ path: test-results/downloaded
638
+ merge-multiple: true
639
+ - name: Generate test dashboard
640
+ run: node scripts/test-dashboard.js --profiles-dir test-results/downloaded
641
+ - name: Upload dashboard
642
+ uses: actions/upload-artifact@v7
643
+ continue-on-error: true
644
+ with:
645
+ name: test-dashboard
646
+ path: test-dashboard.json
647
+ retention-days: 7
648
+ - name: Dashboard summary
649
+ if: always()
650
+ run: |
651
+ echo "## Test Quality Dashboard" >> $GITHUB_STEP_SUMMARY
652
+ if [ -f test-dashboard.json ]; then
653
+ cat test-dashboard.json | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Tests: {d[\"testCount\"]}'); print(f'Suite duration: {d[\"suiteDurationMs\"]}ms'); print(f'Integration skipped: {d[\"integrationSkipped\"]}'); print('Slowest files:'); [print(f'- {item[\"file\"]}: {item[\"durationMs\"]}ms') for item in d.get(\"slowestFiles\", [])[:5]]" >> $GITHUB_STEP_SUMMARY 2>/dev/null || echo "Dashboard generated" >> $GITHUB_STEP_SUMMARY
654
+ fi