Repository navigation
568 lines (547 loc) · 29.5 KB
/
Copy pathci.yml
File metadata and controls
568 lines (547 loc) · 29.5 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
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
# labeled lets the full-ci go-ahead start the full suite; see the
# outside pull request gate below.
types: [opened, synchronize, reopened, labeled]
permissions:
contents: read
# Supersession: a new push to the same PR replaces that PR's in-flight CI
# instead of letting superseded heads keep the full hosted-runner fan-out, and
# main pushes share one group so only the newest merge's run finishes; it
# covers the merges before it, each of which passed the full suite as a PR.
# A label other than full-ci gets a unique, non-cancelling group, so adding
# one never cancels a running suite. Evidence and rationale: the September 12
# Actions starvation report, section 3 "Workflow mechanics to ship first", and
# the October 28 concurrency measurements behind the full-ci gate.
# The compliance workflow deliberately keeps its own event-specific groups; do
# not collapse it onto this simpler shape.
concurrency:
group: ci-${{ github.workflow }}-${{ github.event_name }}-${{ (github.event_name == 'push' && 'main') || (github.event.action == 'labeled' && github.event.label.name != 'full-ci' && github.run_id) || github.event.pull_request.number }}
cancel-in-progress: ${{ !(github.event.action == 'labeled' && github.event.label.name != 'full-ci') }}
# Timeout policy: docs/fm-test-portable-shards.md "Timeouts" owns the three
# tiers and their rationale; tests/fm-ci-workflow.test.sh guards this workflow.
# Each job comment identifies the tier implemented by its executable value.
# Fork opt-out: a fork sets the repository variable FM_CI_SKIP_PUSH to 1 to
# skip the push run, and FM_CI_SKIP_MACOS to 1 to skip the macOS job, without
# a tracked edit that drifts from upstream; with neither set, every job runs
# exactly as before. The skip is a job-level condition, repeated per job
# because workflow syntax has no shared anchor, and never a trigger filter: a
# job skipped by its condition reports success, while a workflow filtered out
# at the trigger stays pending and blocks the merge. CONTRIBUTING.md documents
# the variables.
#
# Outside pull request gate: the full suite (lint, every test lane, macOS and
# the timing aggregate) runs on main pushes, PRs from this repository's own
# branches, PRs whose author has write access (OWNER, MEMBER, COLLABORATOR),
# and PRs carrying the full-ci label, which the maintainer or their fleet adds
# when a PR is picked up for review. Any other PR runs only the coverage guard
# and the invariants, and the Full suite job fails naming the label, so no
# outside PR looks fully tested before the go-ahead. A label event for any
# other label runs nothing: a PR still waiting for the go-ahead already carries
# the failing verdict from its push. Contributors already lint and test
# locally through no-mistakes. GitHub still publishes a skipped check for every
# job of that label event's run, and a newer skipped check with a job's own name would
# replace and mask an earlier failure, so each job that skips there takes a
# "Not run for label - " name prefix instead. The condition is repeated per job
# for the same reason as the fork opt-out; CONTRIBUTING.md documents the label.
jobs:
lint:
name: ${{ (github.event.action == 'labeled' && github.event.label.name != 'full-ci') && 'Not run for label - ' || '' }}Lint ${{ matrix.partition }}
runs-on: ubuntu-24.04
if: ${{ !(github.event_name == 'push' && vars.FM_CI_SKIP_PUSH == '1') && !(github.event.action == 'labeled' && github.event.label.name != 'full-ci') && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository || github.event.pull_request.author_association == 'OWNER' || github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' || contains(github.event.pull_request.labels.*.name, 'full-ci')) }}
# Normal tier (see the timeout policy above).
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
partition: [1, 2]
steps:
- uses: actions/checkout@v6
- name: Install pinned ShellCheck
run: |
set -eu
bin/fm-install-shellcheck.sh "$RUNNER_TEMP/bin"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Install pinned actionlint
run: |
set -eu
bin/fm-install-actionlint.sh "$RUNNER_TEMP/bin"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
# Single owner of the lint definition (shell file set, config, version,
# and GitHub workflow lint). Do not re-spell the checks here; keep CI
# and the pre-push gate on this script so a self-broken ci.yml still
# fails locally before merge.
- name: Lint canonical partition
env:
# Fail closed rather than lint uncapped when a configured per-root
# bound (wall deadline or memory rlimit) cannot be enforced here.
FM_LINT_REQUIRE_BOUNDS: '1'
FM_LINT_JOBS: '1'
run: |
set -eu
mkdir -p "$RUNNER_TEMP/fm-lint"
bin/fm-lint.sh --partition "${{ matrix.partition }}of${{ strategy.job-total }}" \
--telemetry "$RUNNER_TEMP/fm-lint/partition-${{ matrix.partition }}.tsv"
- name: Upload lint telemetry
if: always()
uses: actions/upload-artifact@v4
with:
name: fm-lint-telemetry-${{ matrix.partition }}
path: |
${{ runner.temp }}/fm-lint/partition-${{ matrix.partition }}.tsv
${{ runner.temp }}/fm-lint/partition-${{ matrix.partition }}.roots.tsv
if-no-files-found: warn
# Deterministic proof that portable parallel shards + portable serial + Herdr
# equal the complete tests/*.test.sh inventory with no missing or duplicates,
# and that the portable serial CI shards partition that serial lane exactly.
test-coverage:
name: ${{ (github.event.action == 'labeled' && github.event.label.name != 'full-ci') && 'Not run for label - ' || '' }}Test coverage guard
runs-on: ubuntu-24.04
if: ${{ !(github.event_name == 'push' && vars.FM_CI_SKIP_PUSH == '1') && !(github.event.action == 'labeled' && github.event.label.name != 'full-ci') }}
# Fast tier: the coverage guard is a seconds-long local computation.
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- name: Prove complete regression partition
run: bin/fm-test-run.sh --check-coverage
# Two duration-balanced portable parallel shards of the Phase 2 proven-isolated
# set only. Composition owner: bin/fm-test-run.sh (docs/fm-test-portable-shards.md).
tests-portable-parallel-1:
name: ${{ (github.event.action == 'labeled' && github.event.label.name != 'full-ci') && 'Not run for label - ' || '' }}Behavior portable parallel 1
runs-on: ubuntu-24.04
if: ${{ !(github.event_name == 'push' && vars.FM_CI_SKIP_PUSH == '1') && !(github.event.action == 'labeled' && github.event.label.name != 'full-ci') && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository || github.event.pull_request.author_association == 'OWNER' || github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' || contains(github.event.pull_request.labels.*.name, 'full-ci')) }}
# Normal tier (see the timeout policy above).
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install pinned ShellCheck
run: |
set -eu
bin/fm-install-shellcheck.sh "$RUNNER_TEMP/bin"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Install pinned actionlint
run: |
set -eu
bin/fm-install-actionlint.sh "$RUNNER_TEMP/bin"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Install tasks-axi
run: |
set -eu
npm install -g tasks-axi
tasks-axi --version
- name: Install the Pi package for the Pi extension tests
run: |
set -eu
npm install -g @earendil-works/pi-coding-agent
npm ls -g --depth 0 @earendil-works/pi-coding-agent
- name: Run portable parallel shard 1
run: |
set -eu
mkdir -p "$RUNNER_TEMP/fm-test"
bin/fm-test-run.sh --lane portable-parallel-1 \
--fail-on-gate-skip 'Pi extension typecheck prerequisite not found' \
--json "$RUNNER_TEMP/fm-test/fm-test-timing-portable-parallel-1.json"
- name: Upload shard 1 timing artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: fm-test-timing-portable-parallel-1
path: ${{ runner.temp }}/fm-test/fm-test-timing-portable-parallel-1.json
if-no-files-found: warn
tests-portable-parallel-2:
name: ${{ (github.event.action == 'labeled' && github.event.label.name != 'full-ci') && 'Not run for label - ' || '' }}Behavior portable parallel 2
runs-on: ubuntu-24.04
if: ${{ !(github.event_name == 'push' && vars.FM_CI_SKIP_PUSH == '1') && !(github.event.action == 'labeled' && github.event.label.name != 'full-ci') && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository || github.event.pull_request.author_association == 'OWNER' || github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' || contains(github.event.pull_request.labels.*.name, 'full-ci')) }}
# Normal tier (see the timeout policy above).
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install pinned ShellCheck
run: |
set -eu
bin/fm-install-shellcheck.sh "$RUNNER_TEMP/bin"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Install pinned actionlint
run: |
set -eu
bin/fm-install-actionlint.sh "$RUNNER_TEMP/bin"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Install tasks-axi
run: |
set -eu
npm install -g tasks-axi
tasks-axi --version
- name: Run portable parallel shard 2
run: |
set -eu
mkdir -p "$RUNNER_TEMP/fm-test"
bin/fm-test-run.sh --lane portable-parallel-2 \
--json "$RUNNER_TEMP/fm-test/fm-test-timing-portable-parallel-2.json"
- name: Upload shard 2 timing artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: fm-test-timing-portable-parallel-2
path: ${{ runner.temp }}/fm-test/fm-test-timing-portable-parallel-2.json
if-no-files-found: warn
# Required portable serial remainder: watcher, lock, AFK, tmux, daemon,
# ambiguous, and other stateful tests. Real Herdr stays in tests-herdr.
# Split across separate runners so no two of these stateful scripts ever share
# a machine: each shard is still strictly serial in itself. Shard membership
# and the shard count both belong to bin/fm-test-run.sh, which refuses a lane
# whose "ofN" disagrees with it (docs/fm-test-portable-shards.md).
tests-portable-serial:
name: ${{ (github.event.action == 'labeled' && github.event.label.name != 'full-ci') && 'Not run for label - ' || '' }}Behavior portable serial ${{ matrix.shard }}
runs-on: ubuntu-24.04
if: ${{ !(github.event_name == 'push' && vars.FM_CI_SKIP_PUSH == '1') && !(github.event.action == 'labeled' && github.event.label.name != 'full-ci') && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository || github.event.pull_request.author_association == 'OWNER' || github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' || contains(github.event.pull_request.labels.*.name, 'full-ci')) }}
# Normal tier (see the timeout policy above).
timeout-minutes: 30
strategy:
# Every shard reports so one failure never hides another shard's result.
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install pinned ShellCheck
run: |
set -eu
bin/fm-install-shellcheck.sh "$RUNNER_TEMP/bin"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Install pinned actionlint
run: |
set -eu
bin/fm-install-actionlint.sh "$RUNNER_TEMP/bin"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Require tmux for e2e tests
run: |
set -eu
command -v tmux >/dev/null || {
echo "::error::tmux is required for real afk injection e2e coverage"
exit 1
}
tmux -V
- name: Install tasks-axi for backlog-handoff delegation
run: |
set -eu
npm install -g tasks-axi
tasks-axi --version
# Runtime-facing Pi extension probes need the installed public package
# to avoid capability skips. tests/fm-pi-primary-types.test.sh owns its
# separate compiler dependencies.
- name: Install the Pi package for the Pi extension tests
run: |
set -eu
npm install -g @earendil-works/pi-coding-agent
npm ls -g --depth 0 @earendil-works/pi-coding-agent
- name: Run portable serial shard ${{ matrix.shard }}
env:
# job-total rather than a literal, so shrinking or growing the matrix
# without matching bin/fm-test-run.sh is refused instead of quietly
# leaving a shard of the required lane unrun.
FM_SERIAL_LANE: portable-serial-${{ matrix.shard }}of${{ strategy.job-total }}
FM_SERIAL_SHARD: ${{ matrix.shard }}
run: |
set -eu
mkdir -p "$RUNNER_TEMP/fm-test"
bin/fm-test-run.sh --lane "$FM_SERIAL_LANE" \
--fail-on-gate-skip 'Pi extension typecheck prerequisite not found' \
--json "$RUNNER_TEMP/fm-test/fm-test-timing-portable-serial-${FM_SERIAL_SHARD}.json"
- name: Upload portable serial shard ${{ matrix.shard }} timing artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: fm-test-timing-portable-serial-${{ matrix.shard }}
path: ${{ runner.temp }}/fm-test/fm-test-timing-portable-serial-${{ matrix.shard }}.json
if-no-files-found: warn
# Required real-Herdr lane: pinned install, serial real-herdr-gated family,
# hard-fail on "skip: herdr not found". Starts on Linux x86_64; if a genuine
# platform invariant fails (focus, cleanup, default-session tripwire), keep
# the failure evidence and move the job to macOS rather than skip assertions.
tests-herdr:
name: ${{ (github.event.action == 'labeled' && github.event.label.name != 'full-ci') && 'Not run for label - ' || '' }}Behavior tests (Herdr)
runs-on: ubuntu-24.04
if: ${{ !(github.event_name == 'push' && vars.FM_CI_SKIP_PUSH == '1') && !(github.event.action == 'labeled' && github.event.label.name != 'full-ci') && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository || github.event.pull_request.author_association == 'OWNER' || github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' || contains(github.event.pull_request.labels.*.name, 'full-ci')) }}
# Heavy tier (see the timeout policy above): the last-resort job backstop.
# The family-run step below owns the hang tripwire, so a wedged suite fails
# there with the always() cleanup and timing upload still running.
timeout-minutes: 75
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Require tools for real Herdr
run: |
set -eu
command -v jq >/dev/null || { echo "::error::jq is required"; exit 1; }
command -v python3 >/dev/null || { echo "::error::python3 is required"; exit 1; }
command -v curl >/dev/null || { echo "::error::curl is required"; exit 1; }
command -v tar >/dev/null || { echo "::error::tar is required"; exit 1; }
jq --version
python3 --version
- name: Install pinned Herdr
run: |
set -eu
bin/fm-install-herdr.sh "$RUNNER_TEMP/bin"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Install pinned Treehouse
run: |
set -eu
# Several real-herdr-gated E2E scripts require treehouse for spawn
# worktree acquisition (presentation, workspace-per-home, autodetect).
bin/fm-install-treehouse.sh "$RUNNER_TEMP/bin"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Assert Herdr pin and protocol floor
run: |
set -eu
command -v herdr >/dev/null || { echo "::error::herdr not on PATH after install"; exit 1; }
command -v treehouse >/dev/null || { echo "::error::treehouse not on PATH after install"; exit 1; }
herdr --version
treehouse --version
status=$(herdr status --json)
printf '%s\n' "$status"
version=$(printf '%s' "$status" | jq -r '.client.version // empty')
protocol=$(printf '%s' "$status" | jq -r '.client.protocol // empty')
[ "$version" = "0.7.4" ] || {
echo "::error::expected exact Herdr pin 0.7.4, got ${version:-<empty>}"
exit 1
}
case "$protocol" in
''|*[!0-9]*) echo "::error::could not read client protocol"; exit 1 ;;
esac
[ "$protocol" -ge 16 ] || {
echo "::error::herdr protocol $protocol is below the required floor 16"
exit 1
}
- name: Start default Herdr session for fleet-state tripwire
run: |
set -eu
# Lab provision requires exactly one running default session so the
# default-session tripwire can arm. Start it headless; never use it
# for lab work (tests still refuse default and use fm-lab-* only).
mkdir -p "$RUNNER_TEMP/fm-herdr"
nohup herdr server >"$RUNNER_TEMP/fm-herdr/default-server.log" 2>&1 &
echo $! >"$RUNNER_TEMP/fm-herdr/default-server.pid"
attempt=0
while [ "$attempt" -lt 150 ]; do
running=$(herdr status --json 2>/dev/null | jq -r '.server.running // false' || echo false)
if [ "$running" = true ]; then
herdr session list --json | jq -e '
[.sessions[]? | select(.default == true and .name == "default" and .running == true)]
| length == 1
' >/dev/null
echo "default Herdr session is running"
exit 0
fi
sleep 0.2
attempt=$((attempt + 1))
done
echo "::error::default Herdr server did not become ready"
cat "$RUNNER_TEMP/fm-herdr/default-server.log" || true
exit 1
- name: Snapshot pre-suite Herdr sessions
run: |
set -eu
mkdir -p "$RUNNER_TEMP/fm-herdr"
bin/fm-herdr-ci-cleanup.sh snapshot "$RUNNER_TEMP/fm-herdr/sessions-before.json"
- name: Run real-Herdr family (serial, required)
id: run-real-herdr-family
# Heavy tier step tripwire: above the healthy 7-10 minute wall and far
# below the job backstop, so a hang fails this step and cleanup runs.
timeout-minutes: 20
run: |
set -eu
mkdir -p "$RUNNER_TEMP/fm-test"
# Serial only. Fail if any script reports herdr not found. Live
# harness credential tests stay outside this family (opt-in env only).
bin/fm-test-run.sh --family real-herdr-gated \
--fail-on-gate-skip 'herdr not found' \
--json "$RUNNER_TEMP/fm-test/fm-test-timing-herdr.json"
- name: Cleanup job-owned Herdr lab sessions
id: cleanup-herdr-lab-sessions
if: always()
run: |
set -eu
bin/fm-herdr-ci-cleanup.sh teardown "$RUNNER_TEMP/fm-herdr/sessions-before.json" || {
echo "::warning::lab session cleanup reported a problem; see logs"
# Surface diagnostics without weakening the suite result above.
herdr session list --json || true
exit 1
}
- name: Upload Herdr timing and diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: fm-test-timing-herdr
path: |
${{ runner.temp }}/fm-test/fm-test-timing-herdr.json
${{ runner.temp }}/fm-herdr/sessions-before.json
${{ runner.temp }}/fm-herdr/default-server.log
if-no-files-found: warn
# Aggregate per-lane timing into one summary artifact for critical-path review.
tests-timing-aggregate:
name: ${{ (github.event.action == 'labeled' && github.event.label.name != 'full-ci') && 'Not run for label - ' || '' }}Behavior timing aggregate
runs-on: ubuntu-24.04
# Fast tier: aggregation is seconds of work over lane artifacts.
timeout-minutes: 5
needs:
- tests-portable-parallel-1
- tests-portable-parallel-2
- tests-portable-serial
- tests-herdr
if: ${{ always() && !(github.event_name == 'push' && vars.FM_CI_SKIP_PUSH == '1') && !(github.event.action == 'labeled' && github.event.label.name != 'full-ci') && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository || github.event.pull_request.author_association == 'OWNER' || github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' || contains(github.event.pull_request.labels.*.name, 'full-ci')) }}
steps:
- uses: actions/checkout@v6
- name: Download lane timing artifacts
uses: actions/download-artifact@v4
with:
pattern: fm-test-timing-*
path: ${{ runner.temp }}/fm-test-aggregate
merge-multiple: true
- name: Build aggregate timing summary
run: |
set -eu
mkdir -p "$RUNNER_TEMP/fm-test"
shopt -s nullglob
inputs=("$RUNNER_TEMP"/fm-test-aggregate/fm-test-timing-*.json)
if [ "${#inputs[@]}" -eq 0 ]; then
echo "::warning::no per-lane timing JSON found to aggregate"
exit 0
fi
bin/fm-test-run.sh --aggregate-json \
"$RUNNER_TEMP/fm-test/fm-test-timing-aggregate.json" \
"${inputs[@]}"
- name: Upload aggregate timing artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: fm-test-timing-aggregate
path: ${{ runner.temp }}/fm-test/fm-test-timing-aggregate.json
if-no-files-found: warn
macos-stock-bash:
name: ${{ (github.event.action == 'labeled' && github.event.label.name != 'full-ci') && 'Not run for label - ' || '' }}Stock macOS Bash snapshot compatibility
runs-on: macos-latest
if: ${{ !(github.event_name == 'push' && vars.FM_CI_SKIP_PUSH == '1') && vars.FM_CI_SKIP_MACOS != '1' && !(github.event.action == 'labeled' && github.event.label.name != 'full-ci') && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository || github.event.pull_request.author_association == 'OWNER' || github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' || contains(github.event.pull_request.labels.*.name, 'full-ci')) }}
# Normal tier (see the timeout policy above).
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
- name: Run snapshot consumers with stock Bash
shell: /bin/bash {0}
env:
PATH: /bin:/usr/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin
run: |
set -eu
case "$BASH_VERSION" in
3.2.57*) ;;
*) echo "::error::expected stock macOS Bash 3.2.57, got $BASH_VERSION"; exit 1 ;;
esac
/bin/bash --version | head -1
command -v jq >/dev/null || { echo "::error::jq is required"; exit 1; }
shell_inventory="$RUNNER_TEMP/fm-shell-inventory"
bin/fm-lint.sh --list-files > "$shell_inventory"
parse_fail=0
while IFS= read -r f; do
/bin/bash -n "$f" || { echo "::error::stock macOS Bash 3.2 failed to parse $f"; parse_fail=1; }
done < "$shell_inventory"
[ "$parse_fail" -eq 0 ] || { echo "::error::stock macOS Bash 3.2 parse sweep failed"; exit 1; }
command -v npm >/dev/null || { echo "::error::npm is required to install tasks-axi"; exit 1; }
npm install -g tasks-axi@0.2.6 >/dev/null
PATH="$(npm prefix -g)/bin:$PATH"
export PATH
command -v tasks-axi >/dev/null || { echo "::error::tasks-axi is required for the stock Bash regressions"; exit 1; }
# Each suite declares its own TAP plan; the helper fails an early
# exit 0 that does not reach FM_TEST_COMPLETE or whose ok count
# disagrees with the plan. Per-suite totals live in the test files.
/bin/bash bin/fm-test-tap-verify.sh tests/fm-fleet-snapshot-view.test.sh
/bin/bash bin/fm-test-tap-verify.sh tests/fm-bearings-snapshot.test.sh
# The full public-followup suite is not a stock-bash snapshot; run only
# the empty-lock register regression under real /bin/bash 3.2.
FM_TEST_STOCK_BASH=1 /bin/bash bin/fm-test-tap-verify.sh \
tests/fm-public-followup.test.sh
# Same shape for the watcher's churn-deferral regression: an already-
# marked churn window expands an empty array that only stock Bash
# treats as an unbound variable under set -u.
FM_TEST_STOCK_BASH=1 /bin/bash bin/fm-test-tap-verify.sh \
tests/fm-watch-triage.test.sh
# The fork-free hot-path helpers must stay byte-identical to the
# commands they replace under stock Bash 3.2, which lacks the
# printf %(...)T clock and falls back to date.
/bin/bash bin/fm-test-tap-verify.sh tests/fm-fork-free-helpers.test.sh
FM_TEST_STOCK_BASH=1 FM_TEST_BASH=/bin/bash \
/bin/bash bin/fm-test-tap-verify.sh tests/fm-backend.test.sh
invariants:
name: ${{ (github.event.action == 'labeled' && github.event.label.name != 'full-ci') && 'Not run for label - ' || '' }}Repo invariants
runs-on: ubuntu-24.04
if: ${{ !(github.event_name == 'push' && vars.FM_CI_SKIP_PUSH == '1') && !(github.event.action == 'labeled' && github.event.label.name != 'full-ci') }}
# Fast tier: the invariant checks are seconds-long file comparisons.
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- name: Compatibility pointers must stay intact
run: |
set -eu
[ ! -L CLAUDE.md ] || { echo "::error::CLAUDE.md must be a real @AGENTS.md pointer file, not a symlink"; exit 1; }
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
printf '%s\n' \
'<!-- Points Claude at AGENTS.md via import; edit AGENTS.md, not this file. -->' \
'@AGENTS.md' >"$tmp"
cmp -s CLAUDE.md "$tmp" || { echo "::error::CLAUDE.md must be the canonical @AGENTS.md pointer"; exit 1; }
[ "$(readlink .claude/skills)" = "../.agents/skills" ] || { echo "::error::.claude/skills must be a symlink to ../.agents/skills"; exit 1; }
- name: Personal fleet paths must not be tracked
run: |
set -eu
tracked=$(git ls-files -- data state config projects .no-mistakes)
if [ -n "$tracked" ]; then
echo "::error::Personal fleet paths are tracked in git:"
printf '%s\n' "$tracked"
exit 1
fi
# The Full suite verdict: one check that fails until an outside PR has the
# full-ci go-ahead and otherwise fails when any lane failed or was cancelled.
# It runs even when every lane is skipped, because a skipped job would read
# as passing. On an unrelated label event it skips under the prefixed name
# like every other job: that run is never cancelled, so a verdict it computed
# from its older event could finish after, and mask, a later full-ci pass.
full-suite:
name: ${{ (github.event.action == 'labeled' && github.event.label.name != 'full-ci') && 'Not run for label - ' || '' }}Full suite
runs-on: ubuntu-24.04
# Fast tier: the verdict reads its needed jobs' results.
timeout-minutes: 5
needs:
- lint
- test-coverage
- tests-portable-parallel-1
- tests-portable-parallel-2
- tests-portable-serial
- tests-herdr
- macos-stock-bash
- invariants
if: ${{ always() && !(github.event_name == 'push' && vars.FM_CI_SKIP_PUSH == '1') && !(github.event.action == 'labeled' && github.event.label.name != 'full-ci') }}
steps:
- name: Require the full-ci go-ahead and passing lanes
env:
FM_CI_FULL: ${{ (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository || github.event.pull_request.author_association == 'OWNER' || github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' || contains(github.event.pull_request.labels.*.name, 'full-ci')) }}
FM_CI_LANES_FAILED: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
run: |
set -eu
if [ "$FM_CI_FULL" != true ]; then
echo "::error::The full suite runs on outside pull requests only after a maintainer adds the full-ci label; see CONTRIBUTING.md."
exit 1
fi
if [ "$FM_CI_LANES_FAILED" = true ]; then
echo "::error::A full-suite job failed or was cancelled; see the failed jobs in this run."
exit 1
fi
echo "Full suite passed."