-
Notifications
You must be signed in to change notification settings - Fork 0
1694 lines (1642 loc) · 90.9 KB
/
Copy pathci.yml
File metadata and controls
1694 lines (1642 loc) · 90.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Continuous integration.
#
# Runs on pull requests, on main, and on demand. Deliberately does NOT run the
# container acceptance suite on every commit — that suite builds an image and
# publishes several real streams into it, which takes minutes. It runs on a
# schedule and before a release instead, where its cost buys something.
name: ci
on:
# main only, NOT ["**"]. A PR branch fires both `push` and `pull_request`, so
# matching every branch here produced two runs per commit and left the
# concurrency group below to kill one of them. Those cancellations were
# harmless but they attach cancelled contexts to the head commit, which the
# PR reports as UNSTABLE and `gh pr checks` prints as "fail" -- indis-
# tinguishable at a glance from a real regression, and it cost someone a
# pointless seventeen-job rerun to find that out.
push:
branches: [main]
pull_request:
# The escape hatch for the case the old ["**"] was protecting: a branch with
# no PR open now gets no automatic CI, so run it here by hand. A draft PR
# does the same thing and keeps running on every push.
workflow_dispatch:
# The container suite is the only thing that catches base-image drift: an
# Alpine branch republishing FFmpeg at a new -r revision makes the pinned
# build fail, and that failure is the prompt to bump on purpose. Nothing in
# this repository changes when that happens, so only a timer finds it.
schedule:
- cron: "17 6 * * 1"
# One run per branch. A push that supersedes another cancels it rather than
# leaving both to finish -- which matters here because these suites publish real
# streams and wait on them, so a superseded run can sit for its full 20-minute
# ceiling consuming a runner nobody is waiting on.
concurrency:
# Still needed, but for a different reason than it used to be. The `push`
# trigger above no longer matches PR branches, so there are no push/pull
# twins left to deduplicate. What remains is the ordinary case: pushing twice
# to a branch with an open PR fires two `pull_request` runs, and the older one
# should die rather than sit for its full 20-minute ceiling publishing real
# streams nobody is waiting on.
#
# head_ref || ref_name rather than github.ref: head_ref is the source branch
# on a pull_request and empty on a push, ref_name is the bare branch name on a
# push. Together they give one stable key per branch across both events, which
# github.ref does not -- it is refs/heads/BRANCH on a push and
# refs/pull/N/merge on a pull_request.
group: ci-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# Does this pull request contain anything any suite in here could break?
#
# THE EXPENSIVE HALF OF THIS WORKFLOW RAN ON MARKDOWN. Three documentation-only
# PRs on 2026-08-14 each fired all 28 checks -- the full acceptance matrix,
# three-OS builds, Docker and browser suites -- to validate files no job reads.
# One of them was a single file. See #350.
#
# WHAT THIS GATES, and #378 is the second half of it. #351/#357 stopped the
# acceptance matrix; the three-OS test matrix, the go job and the cross-compile
# kept running, which is most of the twenty minutes #350 was actually
# complaining about. All five now read this output:
#
# go go build, vet, test
# crossplatform test: ubuntu-latest / macos-latest / windows-latest
# ui ui typecheck, lint, build
# cross cross-compile all release targets
# acceptance acceptance: <thirteen suites>
#
# Every name in the right-hand column is a required status context in the
# branch-protection ruleset. That is the whole reason for the shape below.
#
# WHY THIS GATES STEPS AND NOT JOBS. A leg's context has to be added to the
# ruleset by hand or it reports without gating anything. A workflow-level
# `paths:` filter stops the workflow running at all, so a required check never
# reports and the pull request stays pending forever with no way to merge it.
#
# A JOB-LEVEL `if:` ON A MATRIX IS THE SAME BUG WEARING A DISGUISE, and #351
# shipped it. A skipped ordinary job does report, and a skip does satisfy the
# requirement -- but a skipped MATRIX job never expands its matrix, so the
# per-leg contexts are never created at all. The checks list showed one entry
# named literally `acceptance: ${{ matrix.suite }}`, the required
# contexts were absent rather than skipped, and #349 -- a documentation-only
# pull request, the exact case this was built for -- was unmergeable. Fifteen
# green checks and no way in.
#
# So the matrix always expands and every leg reports. What is conditional is
# the WORK: each step carries the `if:`, the job costs a runner allocation and
# nothing else, and the required context reports success either way.
#
# ONE SHAPE FOR ALL FIVE, INCLUDING THE THREE THAT HAVE NO MATRIX. `go`, `ui`
# and `cross` are ordinary jobs today, and a job-level `if:` on them would be
# correct today: a skipped ordinary job reports skipped and satisfies its
# requirement. It is not written that way, because the difference between the
# safe spelling and the outage is one `strategy:` block that nobody would think
# to connect to branch protection while adding it -- a Go-version matrix on
# `go`, a Node-version matrix on `ui`. The rule is therefore flat, has no
# exceptions to remember, and is enforced rather than remembered:
# internal/testenv/docsgate_test.go fails if this output is ever read from a
# job-level `if:`. The price is four runner allocations that do nothing on a
# documentation-only PR, against twenty minutes of compute they replace.
#
# IT FAILS TOWARD RUNNING. `code` is false only when EVERY changed path is
# documentation; anything unrecognised makes it true. A new top-level directory
# gets the full matrix until somebody decides otherwise, which is the right
# direction for a mistake to point.
#
# THIS FILE COUNTS AS CODE, for the reason the container-suites job already
# gives about itself: a change to how the suites run must run the suites.
changes:
name: which changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
code: ${{ steps.detect.outputs.code }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- id: detect
env:
EVENT: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
# Anything that is not a pull request runs everything. A push to main
# and a scheduled run have no diff to reason about.
if [ "$EVENT" != "pull_request" ]; then
echo "code=true" >> "$GITHUB_OUTPUT"
echo "not a pull request: running everything"
exit 0
fi
CHANGED="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")"
echo "changed files:"; echo "$CHANGED" | sed 's/^/ /'
# Every line documentation? Then no suite can be affected by it.
if [ -n "$CHANGED" ] && ! grep -qvE '^(docs/|[^/]*\.md$)' <<<"$CHANGED"; then
echo "code=false" >> "$GITHUB_OUTPUT"
echo "documentation only: skipping the acceptance matrix"
exit 0
fi
echo "code=true" >> "$GITHUB_OUTPUT"
echo "contains code: running everything"
go:
name: go build, vet, test
# The documentation gate, #378. On every STEP below, never on this job --
# see the `changes` job for why that distinction is load-bearing and for
# what it cost the one time it was got wrong.
needs: changes
runs-on: ubuntu-latest
# Above the 15m per-package go test timeout below, so Go's diagnostic
# panic wins the race against this.
timeout-minutes: 25
env:
# #187. The comment below the checkout has said since the beginning that a
# missing FFmpeg makes those tests SKIP, "which is worse, because the run
# goes green having checked nothing" -- and then nothing in this file made
# that impossible. This does. With it set, internal/testenv.FFmpegBinary
# fails and names the binary instead of skipping, so deleting the install
# step above turns the upload gate's tests red rather than silent.
#
# It is set on THIS job only. The crossplatform job installs FFmpeg from
# three different package managers and its post-install checks now assert
# ffprobe as well, but arming a hard failure on runner images I have not
# measured is how a gate becomes a revert; the full suite runs here, and
# here is where the guarantee is worth having. Widening it is a small
# change once someone has watched the three images pass with it.
POLYEMESIS_REQUIRE_FFMPEG: "1"
steps:
# Says out loud why a green check did no work, so a reader of the
# checks list is never left guessing whether this ran or no-opped.
- name: Documentation-only change, so this job did no work
if: needs.changes.outputs.code != 'true'
run: |
echo "::notice title=go build, vet, test::this check did no work -- every changed path was documentation. See the 'which changes' job."
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
if: needs.changes.outputs.code == 'true'
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
if: needs.changes.outputs.code == 'true'
with:
go-version-file: go.mod
cache: true
# polyemesis shells out to ffmpeg and ffprobe, and a large part of the
# test suite asserts on real filter-graph behaviour rather than on
# strings. Without FFmpeg present those tests do not fail — they skip,
# which is worse, because the run goes green having checked nothing.
# WHY A STEP TIMEOUT, when the job already has one.
#
# For the same reason the go test timeout below is kept under it: whichever
# ceiling fires first decides how much you learn. A job timeout kills the
# runner and reports "cancelled" with no indication of which step was
# stuck -- and on 2026-08-04 an acceptance job spent its entire 20 minutes
# inside THIS step, never ran its suite, and looked exactly like the
# stream-waiting hangs issue #38 tracks. It was apt.
#
# Six minutes is far past a normal install (~30s) and far short of the
# job budget, so it fires only on a genuinely stuck mirror.
# KEYED ON THE PINNED ASSET, plus an epoch that can be bumped by hand.
#
# BtbN's `latest` release tag is ROLLING: the same asset name can serve a
# newer build over time. A cache keyed on the name therefore FREEZES the
# build CI runs, which is a deliberate trade and the right one here --
# the Dockerfile pins FFMPEG_VERSION=8.1.2-r0, so a CI FFmpeg that also
# stops moving is closer to what users get, not further from it. The
# weekly scheduled container suite is what catches upstream drift, and it
# builds the image rather than reading this cache.
#
# Bump the -v1 suffix to take a newer build on purpose.
- name: Cache FFmpeg
if: needs.changes.outputs.code == 'true'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /tmp/ffcache
key: ffmpeg-${{ runner.os }}-n8.1-latest-linux64-gpl-8.1-v1
- name: Install FFmpeg
if: needs.changes.outputs.code == 'true'
timeout-minutes: 6
env:
FFCACHE: /tmp/ffcache
run: |
# Ubuntu's own package is 6.1.1, and the Docker image ships 8.1.2.
# Testing against 6.1.1 meant CI never exercised the FFmpeg users
# actually get, and the two differ in ways that matter: 6.1.1 accepts
# an 80-channel amerge where 8.1 stops at 64, and 6.1.1 cannot demux
# multitrack FLV at all. Pin the same 8.1 line the image uses.
set -euo pipefail
cd /tmp
# -c rather than a heredoc: inside a YAML block the heredoc body and
# its terminator are indented, which Python reads as a bad indent and
# bash does not accept as a delimiter.
# RETRIED, because this is one un-mirrored third-party download and
# every Linux job in this file makes it. It has hard-failed two jobs
# in one day -- acceptance-postprod and acceptance-pull -- both by
# sitting on a stalled connection until the 6-minute ceiling fired.
# A stuck mirror is exactly what that ceiling is for, but a single
# attempt turns a transient stall into a red job and a manual rerun.
#
# curl rather than urllib: --retry understands the difference between
# a connection that failed and one that is merely slow, and
# --retry-all-errors covers the 5xx a release CDN returns under load.
# The per-attempt --max-time is well inside the step ceiling, so three
# attempts still cannot outlast it -- the ceiling keeps its meaning.
# A CACHE HIT SKIPS THE DOWNLOAD ENTIRELY, and that is the point.
#
# This artefact failed SEVEN times in one day across Linux and Windows
# -- 503s and stalls from the release CDN -- while already wrapped in
# `curl --retry 3 --retry-all-errors` AND the three-attempt loop below.
# It has retries and still lands red, because a retry re-asks a host
# that is refusing. The cache stops asking.
#
# Same treatment the Playwright browser got: a pinned artefact need not
# be fetched on every run after the first.
if [ -x "$FFCACHE/ffmpeg" ] && [ -x "$FFCACHE/ffprobe" ]; then
echo "ffmpeg restored from cache"
else
for attempt in 1 2 3; do
# --proto/--proto-redir pin the whole exchange to https. -L
# follows redirects, and without these curl will happily follow an
# https -> http hop and fetch the binary CI is about to run over
# plaintext. SonarCloud githubactions:S6506 flagged the copy of
# this block added for the acceptance job; the other two had the
# same hole and are fixed here too, because leaving a known-bad
# copy beside a fixed one is how the fix gets reverted later by
# someone copying the wrong neighbour.
if curl -fsSL --proto '=https' --proto-redir '=https' \
--retry 3 --retry-all-errors --retry-delay 5 \
--connect-timeout 20 --max-time 90 \
-o /tmp/ff.tar.xz \
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n8.1-latest-linux64-gpl-8.1.tar.xz"; then
break
fi
echo "ffmpeg download attempt $attempt failed"
[ "$attempt" = 3 ] && exit 1
sleep 5
done
mkdir -p ffbuild && tar xf ff.tar.xz --strip-components=1 -C ffbuild
mkdir -p "$FFCACHE"
cp ffbuild/bin/ffmpeg ffbuild/bin/ffprobe "$FFCACHE/"
fi
sudo install -m 0755 "$FFCACHE/ffmpeg" /usr/local/bin/ffmpeg
sudo install -m 0755 "$FFCACHE/ffprobe" /usr/local/bin/ffprobe
# Fail loudly rather than silently testing a build without SRT.
ffmpeg -hide_banner -protocols | tr ' ' '\n' | grep -qx srt
ffmpeg -hide_banner -version | head -1
# ffprobe TOO, and not because the install could half-succeed. #187:
# every test that proves the upload probe gate works starts by looking
# ffprobe up and skipping if it is absent, so an ffprobe that stopped
# arriving would have taken the whole gate's verification with it while
# this step still printed an ffmpeg version and passed. The one binary
# that was verified was the one the skips did not turn on.
ffprobe -hide_banner -version | head -1
# The gate is "gofmt named no files", and it has to rest on gofmt having
# RUN. It used to be `gofmt -l ./cmd ./internal | tee /tmp/fmt && test !
# -s /tmp/fmt`, and the default shell for a `run:` block is `bash -e {0}`
# -- no pipefail, unlike an explicit `shell: bash`. So the pipeline's
# status was tee's, gofmt's was discarded, and a gofmt that died on a
# parse error wrote nothing to /tmp/fmt and left `test ! -s` to report
# the tree as correctly formatted. Every way of failing to check produced
# the same green as checking and finding nothing.
#
# A command substitution under set -e instead: gofmt's own exit status
# now fails the step, and the emptiness of its output is a separate
# question asked afterwards.
- name: gofmt
if: needs.changes.outputs.code == 'true'
run: |
set -euo pipefail
unformatted=$(gofmt -l ./cmd ./internal)
if [ -n "$unformatted" ]; then
echo "these files are not gofmt'd:"
echo "$unformatted"
exit 1
fi
echo "gofmt: clean"
- if: needs.changes.outputs.code == 'true'
run: go build ./...
- if: needs.changes.outputs.code == 'true'
run: go vet ./...
# THE PREFLIGHT'S OWN LIVENESS, and it runs HERE because it was not
# running anywhere.
#
# internal/api forces its route coverage ledger through a SECOND m.Run with
# the caller's -run, -skip and -count set aside, so that no test filter can
# switch the ledger off. `make preflight-guard` proves that mechanism is
# still wired by running the package under each of those three switches and
# requiring the preflight's marker to print anyway.
#
# It was reachable only as a prerequisite of `make test`, and this workflow
# invokes `go test` directly -- here and in the crossplatform job -- so the
# guard existed on developer machines and nowhere else. A local-only gate
# described in a comment as a gate is the species this whole change is
# about. ~4s; it runs the package three times with a filter that selects
# almost nothing.
- name: The route-coverage preflight is still wired
if: needs.changes.outputs.code == 'true'
run: make preflight-guard
# THE OTHER HALF OF THE SAME MECHANISM. #217/#223.
#
# The preflight runs in a SECOND m.Run rather than a first, because the
# coverage profile is written by testing.M.after under m.afterOnce, on the
# way out of whichever pass returns first. While the forced pass went
# first, `go test -cover ./internal/api` reported 22.0% for zero tests, for
# one test and for the whole suite alike -- a constant wearing a
# percentage, which #219 already lost a round to.
#
# Reordering is a one-line change and reverting it is a one-line change,
# so it needs a running gate rather than a comment. This one runs the
# package under three different selections and requires the numbers to
# differ. ~60s, dominated by the unfiltered probe, which is the number
# anybody would actually quote.
# THE STEP TIMEOUT THE FOUR BELOW GOT AND THIS ONE DID NOT, and on
# 2026-08-14 this is the step that hung: 24 minutes against a measured
# 98-114s, until the job's own ceiling cancelled it and named nothing.
#
# It can hang for a reason the note at :360 already gives about the
# watchdog -- "a background process holding the suite's stdout ... does
# not merely fail to report, it becomes the hang". The guard captures each
# probe with `out="$(go test ...)"`, and command substitution blocks until
# the pipe has no writers left, NOT until go test exits. A test that
# leaks a child holding inherited stdout hangs the capture after the test
# binary is gone and after Go's own timeout has already fired, so Go's
# diagnostic cannot be the backstop here. Only this can.
#
# 14 rather than something tighter, so the ordering the file argues for
# everywhere else holds: three probes at -timeout 4m is 12m of worst case,
# and Go has to be able to win that race and print the goroutine dump
# before this fires. Below the job's 25 either way, which is the property
# being claimed.
- name: internal/api coverage measures the tests, not the preflight
if: needs.changes.outputs.code == 'true'
timeout-minutes: 14
run: make coverage-instrument-guard
# -race because the engine reconciles from several goroutines and a data
# race here shows up in production as a stream that stops for one viewer.
#
# -timeout because Go's default is 10 minutes PER PACKAGE and internal/db
# runs close to it on a slow runner -- see the cross-platform job below
# for the measurements. -race makes every one of them slower still.
#
# Kept BELOW timeout-minutes on purpose. Go's timeout panics with a
# goroutine dump naming the test that was running; the job timeout just
# kills the runner and tells you nothing. Whichever fires first decides
# how much you learn, so Go's has to.
# POLYEMESIS_LEDGER=strict makes internal/api's route coverage ledger run
# its counterpart proofs from inside the ledger test as well as from their
# own. The registry's whole value is that an excuse cannot discharge on a
# test NAME, only on bytes that actually left the process -- and a `-run`
# filter matching one of the two tests and not the other would leave every
# counterpart undischarged while still printing ok. The full suite runs
# here with no filter, so strict mode costs nothing and closes that door.
- if: needs.changes.outputs.code == 'true'
# 20m, RAISED FROM 15m ON EVIDENCE THAT THIS IS ACCUMULATION AND NOT
# A HANG -- which is the only reason raising a timeout is ever the right
# move rather than the lazy one.
#
# internal/api hit the 15m wall on 2026-08-16 and reported 900.034s. The
# panic named the test that was running, which is exactly why Go's
# timeout is kept below this job's timeout-minutes: 25, and it read
#
# running tests:
# TestAMutationReconcilesEveryProgrammeRatherThanTheDefault (0s)
#
# ZERO SECONDS. Nothing was stuck; the package had simply spent fifteen
# minutes getting there. A hang would have named a test with a large
# number beside it, and the fix for that is never a bigger budget.
#
# internal/api is the largest suite in the tree -- the route ledger
# alone drives every method-pattern pair against a live fixture -- and
# the race detector multiplies all of it. Tests that were spending real
# wall clock on a pacing constant were trimmed in the same change, which
# is the part that should have been unnecessary; this is the part that
# was overdue.
#
# Still five minutes below the job's own timeout, so Go keeps its
# goroutine dump. If this needs raising again, split the package first.
run: POLYEMESIS_LEDGER=strict go test -race -timeout 20m ./...
# The acceptance suites' shared diagnostic helpers, tested here rather
# than in their own job: it is pure shell, needs nothing installed and
# finishes in about a second, so a separate runner would cost more to
# schedule than to run.
#
# It earns a place in CI because the claim it guards is one a reader will
# act on. lib-observe.sh prints "the ceiling is too low; this is not a
# product failure" -- and a diagnostic that says that about a REAL failure
# sends the next person to the wrong component with the authority of a
# printed conclusion. The negative case is the one under test.
#
# THE FOUR SCRIPT STEPS IN THIS JOB CARRY STEP TIMEOUTS, for the reason
# :327-334 and :393-396 give for the two smoke steps: a job timeout names
# nothing, a step timeout names the step. Each of these four runs a shell
# harness that starts and kills processes, so each can hang exactly the way
# #179 hung, and behind this job's timeout-minutes: 25 a hang here costs 25
# minutes and reports "the job was cancelled".
#
# HONEST ABOUT THE NUMBERS: only one of the four has a measurement --
# test-lib-cleanup.sh at 35s, up from 20s when its SIGTERM-deaf case was
# added. The other three were not re-measured here, because running the
# teardown harnesses on a shared developer machine kills processes
# belonging to whatever else is running on it. So these are blast-radius
# ceilings, not fitted bounds: sized to be unreachable by a healthy run and
# far below the job's 25, which is the only property being claimed.
- if: needs.changes.outputs.code == 'true'
run: ./scripts/test-lib-observe.sh
timeout-minutes: 6
# The acceptance suites' own deadline, which fires below this workflow's
# timeout-minutes so a hung suite reports instead of being cancelled.
# Tested here for the reason above and one that is specific to it: the
# watchdog is a background process holding the suite's stdout, so a bug
# in it does not merely fail to report -- it becomes the hang.
- if: needs.changes.outputs.code == 'true'
run: ./scripts/test-lib-watchdog.sh
timeout-minutes: 6
# The SBOM guard, which only ever runs for real inside a release. That is
# exactly how it came to be wrong: it asserted a floor of 100 against a
# document containing 437, so it could not fail on the scenario its own
# comment described, and no one could discover that without cutting a
# release. Running its tests on every PR is the part that makes the fix
# a fix rather than a better guess. Pure jq and shell, about a second.
- if: needs.changes.outputs.code == 'true'
run: ./scripts/test-sbom-guard.sh
timeout-minutes: 5
# The termination guard, and its tests, alongside the SBOM pair above for
# the same reason and one of its own. #210: after #179/#180 the class had
# ONE gate and its jurisdiction was .github/workflows -- a fixture carrying
# #179's body verbatim was dropped into that test's green directory and
# passed. For scripts/ there was no gate at all, which is how
# acceptance-mqtt.sh kept the exact shape that had just been rewritten in
# three other suites. Found by a sweep, not by CI.
#
# Both halves run: the guard against the real tree, and its own red/green
# fixtures. The guard without its fixtures is a check nobody has watched
# fail; the fixtures without the guard oblige nothing. Pure shell, under a
# second each.
- if: needs.changes.outputs.code == 'true'
run: ./scripts/termination-guard.sh
timeout-minutes: 5
- if: needs.changes.outputs.code == 'true'
run: ./scripts/test-termination-guard.sh
timeout-minutes: 5
# The OBS container entrypoint's stop path. #208 was FILED rather than
# fixed on the grounds that it could not be run -- "a blind edit to a
# container entrypoint has two ways to go wrong that a review cannot see".
# Both ways are about a process, not about OBS, so the stop logic moved
# into scripts/obs/lib-stop.sh and this drives it with `sleep` and a
# SIGTERM-deaf stand-in. No OBS, no Xvfb, no container, about six seconds.
- if: needs.changes.outputs.code == 'true'
run: ./scripts/test-obs-stop.sh
timeout-minutes: 6
# Outbound webhooks over a real socket. IN THIS JOB RATHER THAN THE
# acceptance MATRIX, because it needs neither FFmpeg nor `make build` --
# the far end is an http.Server the driver starts on a loopback port, so
# Go alone is the whole dependency and the suite finishes in about 20
# seconds. Scheduling it beside the FFmpeg suites would cost a six-minute
# apt step to test a package that never touches FFmpeg.
#
# ON EVERY PUSH RATHER THAN WEEKLY, which is the opposite of chat-live.yml
# and for the reason that workflow gives for its own schedule: that suite
# measures THEIR behaviour, so its failures arrive with no commit of ours.
# This one measures OURS, contacts nothing outside the runner, and has
# nothing to be flaky about. Its credentialed step skips here; no
# POLY_HOOKS_URL is configured for this workflow and none is needed --
# 29 of its 31 checks run without one.
- if: needs.changes.outputs.code == 'true'
run: ./scripts/acceptance-hooks.sh
timeout-minutes: 6
# The acceptance suites' shared teardown. Same argument as the guard
# above, and a sharper one: this teardown was silently leaking the ingest
# port on every run, which made acceptance-failover fail 10 times out of
# 20 back to back. A harness that measures itself is worse than no
# harness, because a real regression gets dismissed as "that suite is
# flaky". Needs lsof, which this job does not otherwise install.
- name: ./scripts/test-lib-cleanup.sh
if: needs.changes.outputs.code == 'true'
timeout-minutes: 10
run: |
sudo apt-get install -y --no-install-recommends lsof
./scripts/test-lib-cleanup.sh
# The same build and test, on the operating systems polyemesis actually ships
# for.
#
# `make release` cross-compiles darwin and windows binaries and every job
# above then runs on ubuntu, so until this existed the Windows and macOS
# builds were proven to COMPILE and never proven to WORK. That gap matters
# here more than in most projects: this codebase shells out to a child
# process, manages process groups and signals, binds sockets, and builds file
# paths that end up on an FFmpeg command line. Every one of those is a place
# where Linux and Windows genuinely differ.
#
# No -race: it needs cgo, and these builds are CGO_ENABLED=0 on purpose. The
# ubuntu job above runs the race detector, which is where a data race would
# show up anyway -- the goroutine scheduling is not what differs across these
# platforms.
crossplatform:
name: "test: ${{ matrix.os }}"
# The documentation gate, #378. On every STEP below, never on this job --
# see the `changes` job for why that distinction is load-bearing and for
# what it cost the one time it was got wrong.
needs: changes
runs-on: ${{ matrix.os }}
# Above the 15m per-package go test timeout below. Windows needs the room:
# internal/db alone has been measured at 265-300s and once past 600s.
timeout-minutes: 30
strategy:
# Never cancel siblings: when Windows and macOS both break, both answers
# are wanted, and fail-fast would turn one fix into two round trips.
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
# Says out loud why a green check did no work, so a reader of the
# checks list is never left guessing whether this ran or no-opped.
- name: Documentation-only change, so this job did no work
if: needs.changes.outputs.code != 'true'
run: |
echo "::notice title=test: ${{ matrix.os }}::this check did no work -- every changed path was documentation. See the 'which changes' job."
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
if: needs.changes.outputs.code == 'true'
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
if: needs.changes.outputs.code == 'true'
with:
go-version-file: go.mod
cache: true
# FFmpeg on every runner, for the reason the ubuntu job gives: without it
# the measurement tests SKIP rather than fail, and the run goes green
# having checked nothing. A cross-platform job that skipped every test
# that touches FFmpeg would be the most misleading kind of green.
# Same reasoning as the go job's install step.
# KEYED ON THE PINNED ASSET, plus an epoch that can be bumped by hand.
#
# BtbN's `latest` release tag is ROLLING: the same asset name can serve a
# newer build over time. A cache keyed on the name therefore FREEZES the
# build CI runs, which is a deliberate trade and the right one here --
# the Dockerfile pins FFMPEG_VERSION=8.1.2-r0, so a CI FFmpeg that also
# stops moving is closer to what users get, not further from it. The
# weekly scheduled container suite is what catches upstream drift, and it
# builds the image rather than reading this cache.
#
# Bump the -v1 suffix to take a newer build on purpose.
- name: Cache FFmpeg
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /tmp/ffcache
key: ffmpeg-${{ runner.os }}-n8.1-latest-linux64-gpl-8.1-v1
# Linux only: macOS installs from brew and Windows caches its own
# zip below. Without this the step runs on all three and stores an
# empty directory under two keys that nothing ever reads.
if: runner.os == 'Linux' && needs.changes.outputs.code == 'true'
- name: Install FFmpeg (Linux)
timeout-minutes: 6
if: runner.os == 'Linux' && needs.changes.outputs.code == 'true'
env:
FFCACHE: /tmp/ffcache
run: |
# Ubuntu's own package is 6.1.1, and the Docker image ships 8.1.2.
# Testing against 6.1.1 meant CI never exercised the FFmpeg users
# actually get, and the two differ in ways that matter: 6.1.1 accepts
# an 80-channel amerge where 8.1 stops at 64, and 6.1.1 cannot demux
# multitrack FLV at all. Pin the same 8.1 line the image uses.
set -euo pipefail
cd /tmp
# -c rather than a heredoc: inside a YAML block the heredoc body and
# its terminator are indented, which Python reads as a bad indent and
# bash does not accept as a delimiter.
# RETRIED, because this is one un-mirrored third-party download and
# every Linux job in this file makes it. It has hard-failed two jobs
# in one day -- acceptance-postprod and acceptance-pull -- both by
# sitting on a stalled connection until the 6-minute ceiling fired.
# A stuck mirror is exactly what that ceiling is for, but a single
# attempt turns a transient stall into a red job and a manual rerun.
#
# curl rather than urllib: --retry understands the difference between
# a connection that failed and one that is merely slow, and
# --retry-all-errors covers the 5xx a release CDN returns under load.
# The per-attempt --max-time is well inside the step ceiling, so three
# attempts still cannot outlast it -- the ceiling keeps its meaning.
# A CACHE HIT SKIPS THE DOWNLOAD ENTIRELY, and that is the point.
#
# This artefact failed SEVEN times in one day across Linux and Windows
# -- 503s and stalls from the release CDN -- while already wrapped in
# `curl --retry 3 --retry-all-errors` AND the three-attempt loop below.
# It has retries and still lands red, because a retry re-asks a host
# that is refusing. The cache stops asking.
#
# Same treatment the Playwright browser got: a pinned artefact need not
# be fetched on every run after the first.
if [ -x "$FFCACHE/ffmpeg" ] && [ -x "$FFCACHE/ffprobe" ]; then
echo "ffmpeg restored from cache"
else
for attempt in 1 2 3; do
# --proto/--proto-redir pin the whole exchange to https. -L
# follows redirects, and without these curl will happily follow an
# https -> http hop and fetch the binary CI is about to run over
# plaintext. SonarCloud githubactions:S6506 flagged the copy of
# this block added for the acceptance job; the other two had the
# same hole and are fixed here too, because leaving a known-bad
# copy beside a fixed one is how the fix gets reverted later by
# someone copying the wrong neighbour.
if curl -fsSL --proto '=https' --proto-redir '=https' \
--retry 3 --retry-all-errors --retry-delay 5 \
--connect-timeout 20 --max-time 90 \
-o /tmp/ff.tar.xz \
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n8.1-latest-linux64-gpl-8.1.tar.xz"; then
break
fi
echo "ffmpeg download attempt $attempt failed"
[ "$attempt" = 3 ] && exit 1
sleep 5
done
mkdir -p ffbuild && tar xf ff.tar.xz --strip-components=1 -C ffbuild
mkdir -p "$FFCACHE"
cp ffbuild/bin/ffmpeg ffbuild/bin/ffprobe "$FFCACHE/"
fi
sudo install -m 0755 "$FFCACHE/ffmpeg" /usr/local/bin/ffmpeg
sudo install -m 0755 "$FFCACHE/ffprobe" /usr/local/bin/ffprobe
# Fail loudly rather than silently testing a build without SRT.
ffmpeg -hide_banner -protocols | tr ' ' '\n' | grep -qx srt
ffmpeg -hide_banner -version | head -1
- name: Install FFmpeg (macOS)
if: runner.os == 'macOS' && needs.changes.outputs.code == 'true'
# BREW CANNOT SUPPLY 8.1, AND THAT IS RECORDED RATHER THAN HIDDEN.
# `ffmpeg` is 9.0 and there is no `ffmpeg@8` formula -- the versioned ones
# jump from 7.1 straight to 9.0 -- and BtbN publishes no macOS build. So
# this arm cannot have the parity Linux and Windows now share, and macOS
# tests a MAJOR VERSION AHEAD of what the image ships.
#
# Left as brew deliberately: 7.1 would be behind rather than ahead, and
# building 8.1 from source costs more than this job is worth. The version
# is printed by the step below, so the divergence shows up in every run
# instead of being discovered by a test failing oddly.
run: brew install ffmpeg
# Windows pays the same CDN tax as Linux -- this artefact failed twice
# here today -- so it gets the same treatment. A separate key because it
# is a different asset, and a separate step because the Linux cache above
# is gated to runner.os == 'Linux'.
- name: Cache FFmpeg (Windows)
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
if: runner.os == 'Windows' && needs.changes.outputs.code == 'true'
with:
path: ${{ runner.temp }}/ffcache
key: ffmpeg-${{ runner.os }}-n8.1-latest-win64-gpl-8.1-v1
- name: Install FFmpeg (Windows)
if: runner.os == 'Windows' && needs.changes.outputs.code == 'true'
# THE SAME BUILD THE LINUX ARM INSTALLS, not choco, for two reasons and
# the second is the important one.
#
# Reliability: `choco install ffmpeg` took a 504 from
# community.chocolatey.org and left the next step reporting "'ffmpeg' is
# not recognized" -- a red job on an install that silently did not happen,
# and the third distinct FFmpeg-install failure this file saw in one day.
#
# VERSION PARITY, which is what actually matters. The Linux arm pins n8.1
# and says why: the image ships 8.1.2, and 6.1.1 "accepts an 80-channel
# amerge where 8.1 stops at 64, and cannot demux multitrack FLV at all".
# That argument is about FFmpeg, not about Linux. choco resolved to
# whatever it held, so this job tested a version nobody ships while
# carrying a comment explaining why that is wrong.
#
# BtbN publishes ffmpeg-n8.1-latest-win64-gpl-8.1.zip from the SAME
# release the Linux arm downloads: one source, one version, one retry.
run: |
$ErrorActionPreference = 'Stop'
$url = 'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n8.1-latest-win64-gpl-8.1.zip'
$cache = "$env:RUNNER_TEMP\ffcache"
# A CACHE HIT SKIPS THE DOWNLOAD. See the Linux cache step for the
# argument: this artefact failed seven times in one day across both
# platforms while already carrying retries, and a retry re-asks a host
# that is refusing.
if (Test-Path "$cache\ffmpeg.exe") {
Write-Host 'ffmpeg restored from cache'
} else {
for ($i = 1; $i -le 3; $i++) {
try { Invoke-WebRequest -Uri $url -OutFile "$env:RUNNER_TEMP\ff.zip" -TimeoutSec 90; break }
catch { if ($i -eq 3) { throw }; Write-Host "ffmpeg download attempt $i failed"; Start-Sleep -Seconds 5 }
}
Expand-Archive -Path "$env:RUNNER_TEMP\ff.zip" -DestinationPath "$env:RUNNER_TEMP\ff" -Force
$src = (Get-ChildItem "$env:RUNNER_TEMP\ff" -Filter bin -Recurse -Directory | Select-Object -First 1).FullName
if (-not $src) { throw 'no bin/ directory in the FFmpeg archive' }
New-Item -ItemType Directory -Force -Path $cache | Out-Null
Copy-Item "$src\*" -Destination $cache -Force
}
$bin = $cache
if (-not (Test-Path "$bin\ffmpeg.exe")) { throw 'no ffmpeg.exe after install' }
# GITHUB_PATH, not $env:PATH: the latter dies with this step, which is
# exactly how the choco failure stayed invisible until the NEXT step
# could not find the binary.
Add-Content -Path $env:GITHUB_PATH -Value $bin
# BOTH binaries, on all three. #187: the skip helpers key off ffprobe and
# this step only ever ran ffmpeg, so the binary that decides whether the
# upload gate's tests run at all was the one nothing here looked at. The
# go job additionally sets POLYEMESIS_REQUIRE_FFMPEG so a missing one is a
# test failure rather than a skip; this job does not, deliberately -- see
# the note on that env block.
#
# TWO STEPS rather than a two-line one. The default shell on the Windows
# runner is pwsh, whose wrapper exits with the LAST command's status, so a
# two-line block would have reported ffprobe's result and discarded
# ffmpeg's -- the same discarded-exit-status shape as the gofmt pipeline in
# the go job. A step boundary checks each one.
- name: FFmpeg version
if: needs.changes.outputs.code == 'true'
run: ffmpeg -hide_banner -version
- name: ffprobe version
if: needs.changes.outputs.code == 'true'
run: ffprobe -hide_banner -version
- if: needs.changes.outputs.code == 'true'
run: go build ./...
- if: needs.changes.outputs.code == 'true'
run: go vet ./...
# Go's default timeout is 10 minutes PER PACKAGE, and internal/db lands
# close enough to it on Windows that ordinary runner variance decides the
# build. Measured on ONE unchanged tree: 264s, 297s, and once past 600s,
# where it failed -- a commit whose identical code had passed an hour
# earlier.
#
# The cause is not a slow test but a slow platform. internal/db runs
# against modernc.org/sqlite, a pure-Go SQLite that is roughly 40x slower
# on a Windows runner than on a developer machine: ~6s locally against
# ~265-300s there.
#
# What that cost actually WAS: schema DDL plus six migrations, executed
# once per test rather than once per package. The test helpers now build
# one migrated database and copy the file. Measured on this runner after
# that change, against the 265-300s above:
#
# internal/db 46s
# internal/api 69s
# internal/engine 23s
# internal/recording 7s
#
# The whole windows-latest job is now under three minutes, so the ~40x
# platform ratio that made this package a coin flip against Go's
# per-package ceiling is gone -- it is the same pure-Go SQLite on the same
# slow filesystem, asked to do the work once instead of 181 times.
#
# 15m stays. It is now ~20x the slowest package rather than ~3x, which is
# slack rather than a reason to retune: the failure it exists for is a
# test that HANGS, and that failure does not get less likely because the
# suite got faster.
#
# 15m is well past any observed Windows run for the slowest package, and it is
# deliberately BELOW this job's timeout-minutes. Go's timeout panics with
# a goroutine dump naming the running test; the job timeout just kills the
# runner and tells you nothing. Whichever fires first decides how much you
# learn, so Go's has to.
- if: needs.changes.outputs.code == 'true'
run: go test -timeout 15m ./...
# Build and RUN it. Compiling proves the code is valid for the platform;
# it does not prove the process comes up.
#
# The failures this catches are the ones a compiler cannot: a data
# directory created with a path separator the OS does not accept, a
# listener that binds differently, an embedded asset that resolves on one
# filesystem and not another. `make release` already cross-compiles these
# targets, so before this the Windows binary had never been executed by
# anything.
#
# bash on all three: windows-latest ships Git Bash, so one script serves
# every runner rather than a PowerShell fork nobody maintains.
#
# Built in a step of its own so each half carries a ceiling that means
# something (#198). These two things are unrelated: the build is the long
# pole on a cold Windows cache, and the serve check is 2-4s measured
# (n=21). Sharing one step forced its timeout to be sized for the build,
# which put a 10-minute ceiling on a 3-second assertion and hid "the
# Windows build cache was cold today" inside a step called "check it
# serves". Split, the timing column says which one it was.
#
# -cover, so the smoke run MEASURES itself. See the covdata step at the
# end of this job for what that is for and what it costs.
- name: Build the smoke binary
if: needs.changes.outputs.code == 'true'
timeout-minutes: 8
shell: bash
run: go build -cover -o polyemesis-smoke ./cmd/polyemesis
# timeout-minutes because this step BACKGROUNDS A PROCESS, and a step that
# holds a pid can wait on something that never comes. 2 against a measured
# 2-4s (n=21) is still 30x headroom now that the build is not inside it.
# Deliberately below the job's timeout-minutes: 30, per the discipline at
# :154-158 and :305-311 -- whichever timeout fires first decides how much
# you learn, so the narrowest one has to.
- name: Start the server and check it serves
if: needs.changes.outputs.code == 'true'
timeout-minutes: 2
shell: bash
run: |
set -uo pipefail
mkdir -p covdata/serve
GOCOVERDIR=covdata/serve ./polyemesis-smoke -addr 127.0.0.1:8123 -data ./smoke-data -log warn &
pid=$!
# THE CLEANUP IS A TRAP, AND THE VERDICT IS EMITTED BEFORE IT RUNS.
#
# This step used to kill the server and then block in an unbounded
# shell `wait` on the server pid before printing its answer. That wait
# never returns against a `kill` Git-Bash cannot deliver to a native
# .exe: it hung for 28 minutes and was cancelled by the job timeout,
# discarding an answer the loop below had already decided in 2 seconds.
# Establishing that the branch was innocent then took building both
# refs, hashing binaries and surveying 14 runs, to recover a fact this
# step knew and threw away.
#
# A trap covers more than the old inline kill did (any early exit, not
# just the one path), and best-effort is sufficient: nothing later in
# this job binds 8123, polyemesis-smoke is built once and never
# rebuilt so there is no Windows sharing violation to fear, and the
# runner VM is destroyed at the end of the job.
trap 'kill "$pid" 2>/dev/null || true' EXIT
ok=no
for _ in $(seq 1 40); do
sleep 0.5
if curl -fsS http://127.0.0.1:8123/api/v1/health >/dev/null 2>&1; then ok=yes; break; fi
done
if [ "$ok" != yes ]; then
echo "the server never answered /health on ${{ matrix.os }}"
exit 1
fi
echo "server started and served /health on ${{ matrix.os }}"
# Push an actual broadcast through it, on every platform.
#
# /health proves the process comes up. It does not prove the product
# works: that needs a stream arriving, a layout being probed, two
# destinations compiling different routing graphs, FFmpeg children being
# spawned and supervised, and audio landing in a container. Those are the
# parts built on process groups, signals and file paths -- precisely
# where Windows differs -- so "it serves /health on Windows" was never
# the claim worth making.
#
# The stream is injected into the relay hub rather than pushed over SRT,
# because libsrt is not guaranteed in a runner's FFmpeg (Homebrew's has
# none). That substitutes only the ingest hop; everything downstream is
# the real path. SRT ingest itself is covered by the acceptance suites on
# ubuntu.
#
# Verification is a measurement, not an exit status: each destination
# gets a different pair of the three input tones, and the check reads
# per-band energy back out of the file. A destination that silently
# carried the wrong mix would pass any check that only asked whether
# FFmpeg exited 0.
#
# timeout-minutes for the same reason as the step above: it backgrounds a
# process. 8 against a measured 91-93s (n=21) is roughly 5x headroom, and
# it is below the job's 30 on purpose -- a step timeout names the step
# that hung, a job timeout names nothing.
- name: Push a broadcast through it and measure the output
if: needs.changes.outputs.code == 'true'
timeout-minutes: 8
shell: bash
run: |
set -uo pipefail
rm -rf ./data
mkdir -p covdata/bcast
GOCOVERDIR=covdata/bcast ./polyemesis-smoke -addr 127.0.0.1:8099 -data ./data -log warn > bcast-server.log 2>&1 &
pid=$!
# Same shape as the step above: a trap, and the verdict before the
# cleanup. The shell `wait` that used to sit between `rc` and the
# report was unbounded, and on Windows it is unbounded against a kill
# that may never land.
#
# THE PREVIOUS VERSION OF THIS COMMENT SAID "this step is the last in
# the job", AND THAT WAS FALSE. `Upload broadcast artefacts on failure`
# follows it in this same job and uploads bcast-server.log and
# data/recordings/ -- the two paths the server still holds open. The
# trap fires when this step ends, so on the failure path the artefacts
# used to be read while the writer was live, and on Windows the kill
# may never land at all. Low impact (if-no-files-found: ignore, failure
# path only) but the justification rested on an untrue claim, so the
# failure path below now does the work the claim assumed: verdict
# first, then a BOUNDED stop, then the diagnostics.
#
# The trap stays as the backstop for every other exit path, and
# best-effort is still sufficient there: nothing later binds 8099, the
# binary is built once and never rebuilt so there is no Windows sharing
# violation to fear, and the runner VM is destroyed at the end.
trap 'kill "$pid" 2>/dev/null || true' EXIT
go run scripts/smoketest.go
rc=$?
if [ "$rc" -ne 0 ]; then
# THE VERDICT, BEFORE ANY CLEANUP. #179 is what happens when this
# ordering is the other way round.
echo "the broadcast smoke test failed on ${{ matrix.os }} (rc=$rc)"
# Now quiesce the writer, BOUNDED, so the log and the recordings the
# upload step is about to collect are not being appended to while it
# reads them. 10s against a server that shuts down in well under one;
# the ceiling exists because on Windows this kill may never be
# delivered, and an unbounded wait here is the whole of #179.
kill "$pid" 2>/dev/null || true
gone=no
for _ in $(seq 1 40); do
if ! kill -0 "$pid" 2>/dev/null; then gone=yes; break; fi
sleep 0.25
done
if [ "$gone" != yes ]; then
echo "NOTE: the server was still running 10s after the kill, so the"
echo "artefacts below and in the upload step were read from under a"
echo "live writer and may be truncated mid-write."
fi
echo "--- server log ---"
cat bcast-server.log || true
exit 1
fi
# What the two runs above ACTUALLY executed in cmd/polyemesis.
#
# WHY THIS EXISTS. `go test -cover ./cmd/polyemesis` reports ~38%, and
# from that number sixteen functions look untested -- main, run, watch,
# reconcile, disconnect, stop, startMQTT, runService, reportStartup,
# reportTLS, newTLSProvider, managerEngines among them. That number is an
# INSTRUMENT ARTEFACT, the same class as #217 in internal/api: `go test`
# can only see what the test binary runs, and package main's entry points
# are by construction driven by the PROCESS, which the two steps above
# start on all three platforms on every PR. Measured from a real
# instrumented run (darwin/arm64, local):
#
# main 66.7 run 75.7 watch 85.7 startMQTT 100 stop 100
# runService 100 reportStartup 67.5 reportTLS 31.2 disconnect 58.3
#
# Twelve of the sixteen are executed today. The gap was never in the code,
# it was in what we were able to see, and a coverage gap you cannot see is
# the one that gets filled with tests that assert nothing.
#
# THIS STEP NEVER FAILS THE JOB, and that is deliberate. It is an
# instrument, not a gate. A ratchet here would be a threshold nobody
# measured on three platforms yet, and the first thing it would do is fail
# a PR for a reason unrelated to the PR. It also cannot assume data
# exists: a cover-instrumented binary writes its profile at NORMAL EXIT,
# and the steps above stop the server with `kill`, which Git Bash may
# never deliver to a native .exe (the same fact the trap comments above
# are built around). If windows-latest reports nothing, that is a real
# answer about the platform and is printed as one rather than swallowed.
#
# The module-wide total is deliberately NOT printed. A bare server start
# touches little of internal/*, so that number would be low, meaningless,
# and immediately mistaken for the project's coverage.
- name: Report what the smoke run executed in cmd/polyemesis
if: always() && needs.changes.outputs.code == 'true'
shell: bash
run: |