-
-
Notifications
You must be signed in to change notification settings - Fork 24
888 lines (858 loc) · 38.8 KB
/
Copy pathpython-app.yml
File metadata and controls
888 lines (858 loc) · 38.8 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
name: Python CI
on:
push:
branches: [ "master", "main" ]
# No base-branch filter, deliberately. A pull request that targets another
# branch is the bottom half of a stack, and it is the half that still has to
# be reviewed and merged: with `branches` set to main it arrived with four
# checks instead of the thirty this workflow runs, and the gates only spoke
# once it was already at the front of the queue.
#
# This does not double any run. The `push` trigger above is limited to the
# two trunk branches, so a commit on a working branch fires `pull_request`
# alone and a commit on main fires `push` alone. The two jobs that care which
# one it was, `sonar` and `pr-comment`, key on `github.event_name` rather
# than on the base, so they behave for a stacked pull request exactly as they
# do for any other.
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
quality:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
# A conflict resolved carelessly during a rebase can leave markers behind in
# a file no other gate reads. CHANGELOG.md is the usual victim: it collides
# on every parallel branch and nothing lints it, so the markers ride a fully
# green pull request all the way to main. Only the opening and closing
# markers are matched, never a bare row of "=", because that is legitimate
# Markdown (a setext heading underline).
- name: Check for merge-conflict markers
run: |
if git grep -nE '^(<{7} |>{7} )' -- . ; then
echo "::error::Unresolved merge-conflict markers found in the files listed above."
exit 1
fi
- name: Set up Python 3.13
uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
pip install -e .
# The clips are the one committed asset CI never regenerates: an FDTD run
# plus four video encodes each is far too slow for a pull request, and the
# encoders are not bit-reproducible anyway. So nothing could tell that the
# code drawing a clip had moved since the clip was written, and twelve of
# them shipped an ASCII hyphen in their Spanish tick labels for months
# after that was repaired, with every gate green. Each render stamps a
# fingerprint of the code that drew it; this recomputes the fingerprints
# from the sources and names any clip whose one has moved. It parses the
# figure package rather than importing it, so it needs no rendering stack
# and runs in seconds.
- name: Check every committed clip matches the code that draws it
run: python scripts/check_animation_freshness.py
- name: Lint with Ruff
run: ruff check .
# Ruff is also the formatter. Before this step the tree had no formatter at
# all and 673 of its 812 Python files disagreed with any single style; the
# gate is what keeps that from happening again. Markdown is excluded in
# `[tool.ruff]`, so this never touches the documentation snippets.
- name: Check formatting with Ruff
run: ruff format --check .
# `stub/src` is in this list because it was in no path list at all. The
# PyOctaveBand transition shim lives outside the package this repository
# installs, so nothing here ever imported or type checked it, and two
# defects reached its published page that way. Ruff walks the tree from
# `.` and has always reached the shim; mypy takes the paths it is given,
# and this one was never among them.
- name: Type check with Mypy
run: mypy src scripts stub/src
- name: Security check with Bandit
run: bandit -r src
# The PyOctaveBand transition stub under stub/ is built from the tree by a
# workflow_dispatch publish job, so until now the first time anything looked
# at it was the moment it went to PyPI: a README that named the wrong
# warning class and a dependency line with no ceiling were both discovered
# after they had shipped. Building it on every pull request costs seconds
# and moves `twine check` (which is what renders the long description and
# would reject a malformed one) to where a mistake is still cheap.
transition-stub:
name: Transition stub builds and its metadata is sound
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Python 3.13
uses: actions/setup-python@v7
with:
python-version: "3.13"
- name: Build the stub and check its long description
run: |
python -m pip install --upgrade pip build twine
python -m build stub/
twine check stub/dist/*
# The resolver reads the built metadata, not stub/pyproject.toml, so the
# cap that keeps `pip install -U PyOctaveBand` on the line whose API the
# shim re-exports is asserted where it will actually be enforced.
- name: Fail if the built metadata does not cap phonometry below the next major
run: python scripts/check_stub_metadata.py
# Fast gate (runtime deps only, no pytest): the committed
# docs/conformance.json, the docs/CONFORMANCE.md rendered from it, and every
# count quoted from either must equal a fresh `make conformance` run.
conformance:
name: Conformance report up to date
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- name: Set up Python 3.13
uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: 'pip'
- name: Install runtime dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -e .
- name: Regenerate the conformance artefact, the report and the quoted counts
run: make conformance
# `make conformance` now writes as well as reads: it regenerates the report
# and rewrites the counts quoted in .zenodo.json, the docs/ mirror and the
# site frontmatter. So the staleness gate has to cover the whole tree, not
# just the report. Diffing only docs/CONFORMANCE.md would let the rewrite
# fix the prose in the runner's checkout and pass, while the branch keeps
# the stale numbers.
- name: Fail if the committed report or any quoted count is stale
run: |
# --porcelain rather than `git diff`, because a diff is blind to an
# untracked file and the artefact is one of the things this regenerates.
# docs/conformance.json arrived untracked once and the job went green
# while a fresh checkout could not build the site at all.
if [ -n "$(git status --porcelain)" ]; then
git status --porcelain
git diff
echo "::error::The conformance artefact, the report or a count quoted from either is out of date, or a generated file is untracked - run 'make conformance' and commit the result."
exit 1
fi
# Belt and braces, and the command to run locally: the read-only check on
# the committed state. The report can be current while the prose quoting it
# is not; the counts were typed out in three places and two of them fell a
# release behind.
- name: Fail if any page quotes a stale conformance count
run: python scripts/check_conformance_claims.py
# The artefact is committed, so it can be wrong in ways a regeneration
# diff cannot see: a count that no longer matches its rows, a numpy scalar
# where a float belongs, a citation whose split no longer rebuilds it, a
# dead line in the override ratchet. This reads what is committed and says
# which one.
- name: Fail if the committed artefact is not internally consistent
run: python scripts/check_conformance_artifact.py
# Drift gate for the generated Starlight API reference: the committed pages
# under site/src/content/docs/reference/api and the sidebar fragment must
# equal a fresh `make api-docs` run (deterministic, stdlib-only generator).
api-docs:
name: Generated API reference up to date
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Python 3.13
uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: 'pip'
- name: Install runtime dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -e .
- name: Regenerate the API reference
run: make api-docs
- name: Fail if the committed API reference is stale
run: |
if ! git diff --exit-code -- site/src/content/docs/reference/api site/src/generated; then
echo "::error::The generated API reference is out of date - run 'make api-docs' and commit the result."
exit 1
fi
untracked=$(git ls-files --others --exclude-standard -- site/src/content/docs/reference/api site/src/generated)
if [ -n "$untracked" ]; then
echo "::error::Generated API pages are not committed:"
echo "$untracked"
exit 1
fi
# Drift gate for the llms artifacts: the committed llms.txt, llms-full.txt,
# the shards under site/public/llms and the generated overview mirrors under
# docs/ must equal a fresh `make llms` run. Every other generated artifact
# had this gate; these were regenerated by the docs workflow and never
# compared, so a stale commit could not be told from a fresh one.
llms:
name: llms artifacts up to date
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Python 3.13
uses: actions/setup-python@v7
with:
python-version: "3.13"
- name: Regenerate the llms artifacts
run: make llms
- name: Fail if the committed artifacts are stale
run: |
if ! git diff --exit-code -- llms.txt llms-full.txt site/public/llms docs; then
echo "::error::The llms artifacts are out of date - run 'make llms' and commit the result."
exit 1
fi
untracked=$(git ls-files --others --exclude-standard -- site/public/llms docs)
if [ -n "$untracked" ]; then
echo "::error::Generated llms files are not committed:"
echo "$untracked"
exit 1
fi
# Drift gate for the evidence pages the site renders from docs/: the body of
# docs/ERRATA.md is transplanted into the two reference/errata pages (EN +
# ES), so the site can never show a hand-copied or stale version of it. The
# conformance pages are no longer transplanted - they render from
# docs/conformance.json through src/components/Conformance.astro, which the
# `conformance` job gates instead.
site-reports:
name: Generated evidence pages up to date
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Python 3.13
uses: actions/setup-python@v7
with:
python-version: "3.13"
# Stdlib-only generator: no project install needed, it only reshapes text.
- name: Check the transplanted errata pages
run: python scripts/generate_site_reports.py --check
# Evidence gate for docs/ERRATA.md. Each entry there is a permanent public
# statement that a named body or author printed something wrong, so it must
# cite the page it quotes rather than an extraction: PDF text layers delete
# radicals and minus signs silently, and one entry was drafted on an
# extraction that had eaten a square root, and caught in review. The script
# flags any multiplicative claim whose ratio sits on sqrt(2), sqrt(3), pi,
# 2 pi, 1/sqrt(2), ln 2 or a small integer, which is the signature of a lost
# glyph. See scripts/check_errata_evidence.py and CONTRIBUTING.md.
errata-evidence:
name: Errata entries cite their page
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Python 3.13
uses: actions/setup-python@v7
with:
python-version: "3.13"
# Stdlib-only checker: it reads docs/ERRATA.md and nothing else.
- name: Check the errata evidence
run: python scripts/check_errata_evidence.py
# The Python fences of a page form one sequential example: a later fence may
# use names an earlier fence defined, never the reverse. One shipped page
# used names its own figure block defined further down, while a same-named
# variable from a different room sat in scope, so reading top to bottom gave
# numbers that were not the annotated ones. Reader-owned placeholders (the
# reader's measurement, never invented by the page) are registered in the
# script. See scripts/check_fence_names.py and CONTRIBUTING.md.
fence-names:
name: Documentation fences read in order
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Python 3.13
uses: actions/setup-python@v7
with:
python-version: "3.13"
# Stdlib-only checker: it parses the fences and imports nothing else.
- name: Check the fence reading order
run: python scripts/check_fence_names.py
# Coverage gate for the curated quick table in docs/reference/api/index.md: every
# phonometry.__all__ name must have a table row (extra rows such as methods
# or namespace entries are fine). See scripts/check_api_reference.py.
api-reference-table:
name: Curated API quick table covers __all__
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Python 3.13
uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: 'pip'
- name: Install runtime dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -e .
- name: Check docs/reference/api/index.md coverage
run: python scripts/check_api_reference.py
# A pressure of 101 325 and a pressure of 101.325 are both legitimate values
# here: the ISO 3740 family prints kilopascals, IEC 61094-2 and ISO 15186-3
# print pascals, and the result of the mix-up is still a float. No guard by
# magnitude can separate them, because either reading is a real duty
# somewhere else in the tree, so the unit lives in the parameter name and
# this holds it there. It walks the imported package rather than the source
# tree, because ReportMetadata is published from the root and defined in a
# private module, and a scan by file path does not see it.
parameter-units:
name: Every public quantity names its unit
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Python 3.13
uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: 'pip'
- name: Install runtime dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -e .
- name: Check that no pressure, temperature or humidity hides its unit
run: python scripts/check_parameter_units.py
# The committed documentation figures (.github/images) must match a fresh
# `make graphs` run. The rendering + compute stack is pinned in
# requirements-figures.txt so SVG *structure* (elements, text, colours) and
# WebP dimensions/encoding are stable -- matplotlib/fonttools/pillow fix the
# layout, numpy/scipy fix the computed data. The pin is also load-bearing
# for the diagram plates' baked glyph outlines (scripts/diagrams/outline.py
# shapes them through the wheel's FT2Font/libraqm and DejaVu faces): a wheel
# bump rewrites every glyph in all 592 plate files and is reviewed as its
# own regeneration. The comparison itself (scripts/check_figures.py) is
# tolerance-aware rather than a byte diff: GitHub's heterogeneous runner
# CPUs shift a few path coordinates ~1 ULP, which is visually irrelevant but
# breaks a byte compare. Bump the pinned stack together with a fresh
# `make graphs` regeneration.
figures:
name: Documentation figures up to date
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- name: Set up Python 3.13
uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: 'pip'
- name: Install the locked figure-rendering stack
run: |
python -m pip install --upgrade pip
pip install -e .
# Pin the render+compute stack last so it wins over looser deps.
pip install -r requirements-figures.txt
- name: Fail if any label's mathematics does not parse
# Before the regeneration, not after: an unparseable label aborts the
# generation, and this names the file and line instead of leaving a
# traceback in the middle of a four-hundred-figure run. Reads the
# sources, so it costs seconds and covers labels no figure exercises.
run: python scripts/check_mathtext.py
- name: Fail if a Spanish label would keep an English decimal point
# The one defect the language gate structurally cannot see: it compares
# untranslated WORDS, and a number is not a word. Static, so it needs no
# generation run.
run: python scripts/check_decimal_comma.py
- name: Regenerate figures and diagrams
run: make graphs
- name: Regenerate the plates again and demand identical bytes
# The diagram plates bake their labels to glyph outlines and must be
# bit-deterministic: two runs in the same job have to agree byte for
# byte, which is stronger than the tolerance gate below and catches an
# accidentally unordered collection in the glyph store the moment it
# appears.
run: |
mkdir /tmp/plates-first-run
cp .github/images/diagram_*.svg .github/images/sound_power_methods*.svg /tmp/plates-first-run/
python scripts/generate_diagrams.py
for f in /tmp/plates-first-run/*.svg; do
cmp "$f" ".github/images/$(basename "$f")"
done
- name: Fail if any committed figure is stale
# Tolerance-aware compare instead of a byte diff: GitHub's runner fleet is
# hardware-heterogeneous, so the pinned stack computes a few path
# coordinates ~1 ULP apart depending on the CPU the run lands on. That
# sub-pixel drift is irrelevant but breaks a byte diff; the script checks
# SVG structure + numeric tolerance and raster RMS instead, so real figure
# changes still fail while cross-CPU noise passes. See scripts/check_figures.py.
run: python scripts/check_figures.py
- name: Fail if any shaded region is illegible
# Staleness says the figures match the code; it says nothing about
# whether a shaded region can be seen. This measures every filled area
# against the page it is drawn on and fails below the CIEDE2000
# threshold, which is what catches a fill tuned on one theme and left
# invisible on the other. It parses the committed SVGs (a few seconds
# for the whole set) and needs no rendering stack of its own.
run: python scripts/check_figure_contrast.py
- name: Check every line can be seen on the dark page
# The step above measures filled areas. A stroke is not a fill, and the
# ISO 3745 microphone array shipped with its hemisphere drawn in a
# half-opacity grey hairline that composited to 1.3:1 on the dark theme:
# present in the SVG, invisible to a reader, and green on every gate.
# This blends each stroke with its own opacity over the dark page and
# holds hairlines to 3:1 and thicker lines to 2:1, with the grid and axis
# furniture exempt because they are drawn to recede.
run: python scripts/check_stroke_contrast.py
- name: Check the Spanish figures are in Spanish
# The Spanish variant of a figure is the English one with its strings
# looked up in a table at save time, so a string nobody added to the
# table ships in English inside `X_es.svg` while every other gate stays
# green: the page is Spanish, the i18n parity gate sees a translated
# page, this job sees a figure that matches its generator, and the
# figure is in English. That is how roughly two hundred of them reached
# the tree. The lookup itself writes down what it could not translate,
# during the `make graphs` above (no second render, and it cannot change
# a rendered byte), and this fails on any untranslated string the
# committed baseline does not already record -- and on a baseline line
# that has been paid off and not deleted.
run: python scripts/check_figure_language.py
- name: Fail if an annotation on a figure cannot be read
# Two ways a label fails the reader, and nothing else can see either. A
# label across a curve and given no backing is hard to read: the strokes
# of the letters and of the curve are the same weight, and on the dark
# page close to the same lightness. A label something is drawn over is
# not there at all. The figure matches its generator, the contrast steps
# above measure fills and strokes rather than what overlaps what, and
# `svg.fonttype = "path"` means the committed file has no text node to
# find the label in -- so the measurement has to happen while the
# matplotlib artists are alive, which is what the `make graphs` above
# did, for both language editions. This reads it and fails on any label
# over the calibrated number of pixels either way, printing the band
# below that for a person to judge.
run: python scripts/check_figure_annotations.py
# The Python snippets printed in the guides must run. They are the first
# thing a reader copies, and nothing executed them until this job existed:
# the metrology split shipped seven blocks where `from phonometry import
# signals` sat next to `from scipy import signal` and silently rebound the
# name, which Python does not warn about and no other gate can see. The
# script also holds the English and Spanish pages to the same API and skips,
# with a written reason, the pages whose blocks are excerpts of a workflow
# rather than a script.
doc-snippets:
name: Documentation snippets run
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Python 3.13
uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: 'pip'
- name: Install dependencies
# The guides plot and print fiches, so the snippets need the figure and
# report stacks as well as the package itself.
run: |
python -m pip install --upgrade pip
# The audio extra rides along because the io guide's snippets read and
# write the formats it serves; a guide that documents the extra has to
# run under it, the same reason the figure and report requirements are
# here for the pages that draw and render.
pip install -e ".[audio]"
pip install -r requirements-figures.txt -r requirements-reports.txt
- name: Run every snippet the guides print
run: python scripts/check_doc_snippets.py
# The guides are hard-wrapped, so a sentence can wrap onto a "-" or a ">" and
# stop being a sentence: CommonMark ends the paragraph at the marker. One
# variant takes the site build down (unclosed inline maths, which MDX then
# evaluates as JavaScript); the other is silent and ships a quoted block in
# the middle of a paragraph. Needs no dependencies, so it runs on its own
# rather than waiting behind an install.
markdown-hazards:
name: Markdown renders the way it reads
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Python 3.13
uses: actions/setup-python@v7
with:
python-version: "3.13"
- name: Check the hand-written markdown
run: python scripts/check_markdown_hazards.py
# A form feed is one invisible byte, and inside a raw docstring it is the
# backslash and the f of the command that follows it: `\frac` was published
# as `rac`, with the fraction gone and no error anywhere. Guard the class
# rather than the byte. Needs no dependencies, so it runs on its own.
control-characters:
name: No stray control character
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Python 3.13
uses: actions/setup-python@v7
with:
python-version: "3.13"
- name: Check the tracked text files
run: python scripts/check_control_characters.py
# ISO 80000-2 sets a subscript by what it is, and a glyph pair can honestly
# take both slopes: the z of the ISO 9613-2 barrier screening is a
# path-length difference, the z of the ISO 2631-5 dose is a direction, and
# both documents print D_z. So the slope is decided per file, which opens
# with the standard it implements, and two slopes for one symbol inside one
# file is a page contradicting itself. Static and dependency-free, so it runs
# on its own rather than waiting behind an install.
subscript-slope:
name: One meaning per subscript per file
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Python 3.13
uses: actions/setup-python@v7
with:
python-version: "3.13"
- name: Check that no file sets one subscript both ways
run: python scripts/check_subscript_slope.py
# A private module-level constant is a number lifted out of a standard so the
# code that uses it can say where it came from, and one nothing reads is
# either a leftover or the trace of a check the docstring above it still
# promises. The sweep this came from found both: a recommended specimen
# velocity carried and never checked, and a docstring promising a default the
# code did not take. Static and dependency-free, so it runs on its own.
dead-constants:
name: No private constant goes unread
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Python 3.13
uses: actions/setup-python@v7
with:
python-version: "3.13"
- name: Check that every private constant is read somewhere
run: python scripts/check_dead_constants.py
# A conformance row states what a standard prints and what this library
# computes. A row that computes the expected value itself, from the same
# constants, compares a formula with a second copy of it and reports Pass
# whatever the library does; three did, and a wrong sign in the function they
# certified would have gone out green. Counting them needs the delegation
# resolved -- through helpers, across modules, and under whatever name the
# imports gave the library -- so the check is an AST walk rather than a grep.
# Static and dependency-free, so it runs on its own.
conformance-rows:
name: Every conformance row runs the library
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Python 3.13
uses: actions/setup-python@v7
with:
python-version: "3.13"
- name: Check that no row certifies the library without running it
run: python scripts/check_conformance_rows.py
# The committed example .report() fiches (.github/reports) must match a fresh
# `make reports` run. Same drift gate as the figures, one layer further down
# the pipeline: the fiches are what the documentation links to as worked
# examples of what the library prints, so a stale one publishes a wrong
# answer. Nothing checked them until this job existed, and two of them sat a
# plot-styling release behind the code for weeks. The rendering stack is
# pinned across both requirements files (matplotlib/numpy in -figures,
# svglib/reportlab/pypdfium2 in -reports) because every link in the chain
# fixes the rendered page the check compares.
reports:
name: Example report fiches up to date
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Python 3.13
uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: 'pip'
- name: Install the locked fiche-rendering stack
run: |
python -m pip install --upgrade pip
pip install -e .
# Pin the render+compute stack last so it wins over looser deps.
pip install -r requirements-figures.txt -r requirements-reports.txt
- name: Regenerate the example fiches
run: make reports
- name: Fail if any committed fiche is stale
# Tolerance-aware compare instead of a byte diff: GitHub's heterogeneous
# runner CPUs shift a few of the embedded plot's path coordinates ~1 ULP,
# which rewrites the PDF page stream for no visible reason. The script
# compares the extracted text exactly and the rendered page within a
# calibrated pixel tolerance, so a real fiche change fails while
# cross-CPU coordinate noise passes. See scripts/check_reports.py.
run: python scripts/check_reports.py
tests:
runs-on: ${{ matrix.os }}
permissions:
contents: read
# Required for the OIDC-based Codecov upload (no token secret needed).
id-token: write
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.13", "3.14"]
steps:
- uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
pip install -e .
# Heavy third-party oracle sets (the EBU loudness test set, the stipa.info
# certified STIPA bench, the NORAH2 public release) are never fetched here.
# The suites that use them fall back to the committed derived series and
# extracts under tests/data/ - see tests/data/README.md - which is the path
# CI takes, and whose assertions never skip. A handful of cases that exist
# only to exercise a full set do skip here and report as such (the EBU
# programme-audio cases and the full-bench inventory guard); pytest prints
# the resolution in its run header.
- name: Run tests
# -n auto fans the suite out across the runner's cores via pytest-xdist;
# pytest-cov combines the per-worker coverage into a single coverage.xml.
# Pin the numerical thread pools to one thread each so the per-core xdist
# workers do not oversubscribe the CPU with nested BLAS/OpenMP pools.
env:
OMP_NUM_THREADS: "1"
MKL_NUM_THREADS: "1"
OPENBLAS_NUM_THREADS: "1"
NUMEXPR_NUM_THREADS: "1"
VECLIB_MAXIMUM_THREADS: "1"
run: |
pytest -n auto --junitxml=test-results-${{ matrix.os }}-${{ matrix.python-version }}.xml --cov=src --cov-report=xml
- name: Upload Test Results
uses: actions/upload-artifact@v7
with:
name: test-results-${{ matrix.os }}-${{ matrix.python-version }}
path: |
test-results-${{ matrix.os }}-${{ matrix.python-version }}.xml
coverage.xml
if: always()
- name: Upload coverage to Codecov
if: matrix.os == 'ubuntu-latest'
uses: codecov/codecov-action@v7
with:
files: coverage.xml
use_oidc: true
fail_ci_if_error: false
# The main matrix runs against the latest numpy with the pure-Python
# impulse kernel; this job is the only one exercising the numba-jitted
# path (numba pins numpy, so it lives in its own environment).
tests-perf:
name: tests (numba-jitted kernel)
runs-on: ubuntu-latest
permissions:
contents: read
# Declared once for the whole job so the guard below runs under exactly the
# environment it vouches for. Split across the two steps, the guard could
# stay green on its own NUMBA_DISABLE_JIT while the suite lost its copy and
# ran interpreted from end to end.
env:
# conftest.py disables JIT by default (coverage); override it here, this
# is the only job exercising the numba-compiled kernel.
NUMBA_DISABLE_JIT: "0"
# Pin the numerical thread pools to one thread each so the per-core xdist
# workers do not oversubscribe the CPU with nested BLAS/OpenMP pools,
# exactly as the main test job does.
OMP_NUM_THREADS: "1"
MKL_NUM_THREADS: "1"
OPENBLAS_NUM_THREADS: "1"
NUMEXPR_NUM_THREADS: "1"
VECLIB_MAXIMUM_THREADS: "1"
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
# The newest Python the package supports. This job used to sit on 3.13
# because numba trails a new interpreter by a release or two, and while it
# did, the jitted kernel was never built against the newest Python we ship
# for. numba 0.67.0 publishes cp314 wheels and carries the 3.14 classifier,
# and it is the floor in pyproject.toml (numba>=0.67.0), so nothing older
# can resolve here.
# Tracking the newest version is the strictest single choice for this job:
# a compiled extension meeting a new interpreter is the thing most likely
# to break, and the pure-Python kernel is covered on both versions by the
# main matrix regardless.
- name: Set up Python 3.14
uses: actions/setup-python@v7
with:
python-version: "3.14"
cache: 'pip'
- name: Install dependencies (with numba)
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
pip install -e ".[perf,plot]"
# Fails the job if the kernel falls back to the interpreted path, which
# would leave this job green while exercising nothing it exists for.
# Compiling here also fills numba's on-disk cache next to the module, so
# the test workers below load the kernel instead of each compiling a copy.
- name: Check the jitted kernel compiles
run: python scripts/check_jit_kernel.py
- name: Run tests (jitted kernel)
# Same fan-out as the main matrix. The compiled kernel is not a reason
# to stay serial: the step above compiles it once and the workers that
# reach it read the cache in a fraction of a second, against a suite
# that takes about twenty minutes to run one test at a time.
run: pytest -q -n auto
sonar:
needs: tests
runs-on: ubuntu-latest
# Dependabot runs with a restricted token and no access to repository secrets, so
# SONAR_TOKEN is empty there and the scan can only ever fail. Skip it for those runs.
if: >-
github.actor != 'dependabot[bot]' &&
(github.event_name == 'pull_request' || github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Download coverage report
uses: actions/download-artifact@v8
with:
name: test-results-ubuntu-latest-3.13
- name: SonarCloud Scan
uses: SonarSource/sonarqube-scan-action@v8
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
pr-comment:
needs: [quality, tests]
runs-on: ubuntu-latest
if: always() && github.event_name == 'pull_request'
permissions:
contents: read
pull-requests: write
issues: write
steps:
# Full history: the comment is a diff of the committed conformance artefact
# against the same file on the base branch, and a checkout at the default
# depth has only the merge commit to show for it. A missing baseline is not
# an error - the comment falls back to totals and says so - but it makes
# every pull request look like it changed nothing.
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Python 3.13
uses: actions/setup-python@v7
with:
python-version: "3.13"
# Stdlib only. The comment reads two committed JSON files and joins them;
# it does not run the checks. It used to install the whole scientific stack
# and spend 45 s regenerating a report the `conformance` job has already
# proven current, which meant a stale artefact was reported twice and
# fixed by neither.
- name: Download Test Results
uses: actions/download-artifact@v8
with:
pattern: test-results-*
path: test-results
continue-on-error: true
- name: Generate Comment Body
run: python .github/scripts/comment_pr.py
env:
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_RUN_ID: ${{ github.run_id }}
# The branch the artefact is diffed against, and the commit the two
# "full report" links are permalinks to. `github.sha` on a pull request
# is the merge commit, which is not a commit anyone can browse.
GITHUB_BASE_REF: ${{ github.base_ref }}
GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
- name: Post PR Comment
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
const body = fs.readFileSync('pr_comment_body.md', 'utf8');
const marker = '<!-- phonometry-ci-conformance -->';
// Update the single sticky conformance comment instead of posting a
// new one every run: find the existing marked comment and edit it.
const { data: comments } = await github.rest.issues.listComments({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const existing = comments.find(
(c) => c.body && c.body.includes(marker)
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: body,
});
} else {
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: body,
});
}