Repository navigation
730 lines (665 loc) · 36.2 KB
/
Copy pathci.yml
File metadata and controls
730 lines (665 loc) · 36.2 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
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
# Pushes to main queue so each merge gets a test result; PR updates cancel the
# run for the superseded head.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name != 'push' }}
jobs:
# Restore the required relevance contexts removed by the baseline sync (#178).
# A failed comparison fails its gate; a docs-only diff can skip expensive jobs.
gate:
name: Code relevance
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
relevant: ${{ steps.push.outputs.relevant || steps.pr.outputs.relevant }}
steps:
# A push only starts this workflow on main (the branch filter above), so
# the gate has nothing to decide: every push is relevant.
- name: A push is already branch-filtered
id: push
if: github.event_name == 'push'
run: echo 'relevant=true' >> "$GITHUB_OUTPUT"
# Full history, because the three-dot diff names the merge base of the
# PR's base tip and its merge commit, and a stacked PR's merge base sits
# further back still.
- name: Checkout the change
if: github.event_name == 'pull_request'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false # nothing here pushes
fetch-depth: 0
# --no-renames, so a file renamed out of a listed path is reported as a
# deletion under its original name and still counts. -z with a
# NUL-delimited read, so a pathname Git would quote or that contains a
# newline arrives raw and matches the pattern. The base SHA travels
# through env, not template interpolation into the script.
- name: Does the change touch anything the suite exercises?
id: pr
if: github.event_name == 'pull_request'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
relevant=false
# Every pattern is quoted, so the glob survives array assignment and
# fires at `case` time, against the diff -- an unquoted `src/*` would
# expand against the checkout the runner happens to be standing in.
patterns=('src/*' 'test/*' 'scripts/*' 'frontend/*' 'config/*'
'bench/*' 'data/*' 'proofs/*' 'R/*' 'renv/*' 'Project.toml'
'Manifest.toml' 'renv.lock' '.Rprofile' 'install.sh' 'install.jl'
'start.sh' 'docs/occupancy/*' 'docs/occupancy-2026-10.adoc'
'.github/workflows/*')
# The diff goes to a file first because a process substitution hides
# git diff's exit status: an unavailable comparison SHA (a force-push
# on the base branch, for instance) or a repository error would read
# as "no changes", and the gate would post relevant=false -- skipping
# the Julia suite and the proof checker, whose `skipped` conclusion
# satisfies a required check -- so a relevant PR would pass silently.
# A failed diff must fail the gate itself: that is the one failure
# the required-check design cannot afford to skip. As in the
# occupancy gate, which this mirrors.
changed="$RUNNER_TEMP/ci-changed-files"
if ! git diff --no-renames --name-only -z "$BASE_SHA...HEAD" > "$changed"; then
echo "ERROR: git diff $BASE_SHA...HEAD failed" >&2
exit 1
fi
while IFS= read -r -d '' file; do
for pattern in "${patterns[@]}"; do
case "$file" in
$pattern) relevant=true; break 2 ;;
esac
done
done < "$changed"
echo "relevant=$relevant" >> "$GITHUB_OUTPUT"
occupancy_gate:
name: Occupancy relevance
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
relevant: ${{ steps.push.outputs.relevant || steps.pr.outputs.relevant }}
steps:
# All pushes to main are relevant; PRs use the path classification below.
- name: A push to main is relevant
id: push
if: github.event_name == 'push'
run: echo 'relevant=true' >> "$GITHUB_OUTPUT"
# Full history, because the three-dot diff names the merge base of the
# PR's base tip and its merge commit -- a shallow checkout does not have
# both, and a stacked PR's merge base sits further back still.
- name: Checkout the change
if: github.event_name == 'pull_request'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false # nothing here pushes
fetch-depth: 0
- name: Does the change touch the occupancy reading?
id: pr
if: github.event_name == 'pull_request'
env:
# zizmor flags template expressions interpolated into run scripts
# (template-injection), and the gate script is easier to run locally
# with the value in an environment variable in any case. The base SHA
# is GitHub-supplied, but the indirection costs nothing.
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
relevant=false
patterns=('src/core/memory_probe.jl' 'src/core/provenance.jl' 'src/pipeline/tools.jl'
'src/server/routes/pipeline.jl' 'test/occupancy_disagreement_test.jl'
'test/unit/test_occupancy_grade.jl' 'scripts/check-occupancy-grades.jl'
'scripts/lint-occupancy-readings.sh' 'docs/occupancy/*'
'docs/occupancy-2026-10.adoc' 'scripts/check-occupancy.sh'
'scripts/fetch-siblings.sh' 'scripts/install-ci-julia.sh'
'Project.toml' 'Manifest.toml' 'renv.lock' 'config/defaults/tool_versions.yml'
'.github/workflows/*')
# The diff goes to a file first because a process substitution hides
# git diff's exit status: an unavailable comparison SHA or a repository
# error would read as "no changes", and the gate would post
# relevant=false -- skipping the two occupancy jobs, whose `skipped`
# conclusion satisfies a required check -- so a relevant PR would
# pass silently. A failed diff must fail the gate itself: that is
# the one failure the required-check design cannot afford to skip.
changed="$RUNNER_TEMP/occupancy-changed-files"
if ! git diff --no-renames --name-only -z "$BASE_SHA...HEAD" > "$changed"; then
echo "ERROR: git diff $BASE_SHA...HEAD failed" >&2
exit 1
fi
while IFS= read -r -d '' file; do
for pattern in "${patterns[@]}"; do
case "$file" in
$pattern) relevant=true; break 2 ;;
esac
done
done < "$changed"
echo "relevant=$relevant" >> "$GITHUB_OUTPUT"
test:
# The name is a fixed string, it carries no version, and the job has NO matrix.
# Both are required, and the first without the second is a trap that has already
# been sprung here once.
#
# A branch ruleset matches a required status check by the DISPLAY NAME GitHub posts
# for the job. A renamed check does not fail -- it is simply absent, and an absent
# required check can never be satisfied, so every pull request deadlocks until an
# admin bypasses the rule, triggered by nothing more alarming than editing a version
# number.
#
# MEASURED on hyperpolymath/MetaManifold-WebUI#39 (2026-09-21): this job, named exactly `Julia tests` but still
# carrying a 1x1 `strategy.matrix`, posted `Julia tests (1.12.5, ubuntu-24.04)`.
# GitHub appends the matrix combination whenever the name does not already reference
# the matrix, so a 1x1 matrix is still a matrix and the pins were still embedded in
# the check name. The matrix was therefore removed rather than merely renamed around:
# it selected exactly one combination and bought nothing.
#
# The two values it held are literals below, each keeping its own reasoning.
# `test/unit/test_install_pins.jl` asserts, for EVERY job in this file, that the name
# interpolates nothing AND that the job has no matrix -- so this cannot regress
# silently. If a matrix is ever genuinely wanted here, the required context must move
# to a matrix-free aggregator job first.
name: Julia tests
# R/Julia setup and integration tests are expensive, but may not hang for 6h.
timeout-minutes: 90
needs: gate
if: needs.gate.outputs.relevant == 'true'
# Explicit, not ubuntu-latest: the R pin below names an apt package revision built
# for 24.04, and a runner that silently rolled to the next LTS would take that pin
# with it. `runs-on` cannot read `env`, so this is a literal.
runs-on: ubuntu-24.04
# The repository's root .Rprofile sources renv/activate.R, so R started from
# the checkout auto-activates renv and rewrites the library paths. renv is a
# local-development convenience; CI installs into the system library and
# reaches it through R_LIBS_SITE, so the autoloader is switched off here.
# This variable covers the unprivileged R that Julia embeds through RCall.
# It cannot cover the installs themselves, which run under sudo: sudo resets
# the environment, so those commands pass --no-init-file instead.
env:
RENV_CONFIG_AUTOLOADER_ENABLED: "FALSE"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The required "SonarCloud Code Analysis" check is the GitHub App's
# automatic analysis -- nothing in this repository configures or runs it,
# so its rules cannot be read here and its failure arrives only after the
# push. This script reproduces the rule that failed the gate and the one
# that came with it, needs no toolchain, and runs in about 0.1s: first,
# before anything is installed, so the message beats the wait.
- name: Check the Sonar reliability rules the gate enforces
run: bash scripts/lint-sonar-reliability.sh
- name: Set up Julia
run: bash scripts/install-ci-julia.sh
- name: Parse Julia sources before dependency setup
run: julia scripts/check-julia-syntax.jl
- name: Cache Julia packages
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.julia/artifacts
~/.julia/packages
~/.julia/registries
key: julia-${{ runner.os }}-${{ hashFiles('Manifest.toml', 'config/defaults/tool_versions.yml') }}
# Every external version CI installs is read from the committed pin file, so CI
# and a developer's machine cannot drift apart. A temporary environment is used
# because the project environment cannot be instantiated until R is present:
# RCall's build step requires it.
- name: Read pinned versions
run: |
julia -e '
using Pkg
Pkg.activate(temp = true)
Pkg.add("YAML")
using YAML
pins = YAML.load_file("config/defaults/tool_versions.yml")
open(ENV["GITHUB_ENV"], "a") do io
println(io, "R_APT_VERSION=", pins["runtimes"]["r"]["apt_version"])
println(io, "BUN_VERSION=", pins["toolchain"]["bun"]["version"])
println(io, "CUTADAPT_SPEC=cutadapt==", pins["tools"]["cutadapt"]["version"])
println(io, "MULTIQC_SPEC=multiqc==", pins["tools"]["multiqc"]["version"])
fastqc = pins["tools"]["fastqc"]["archives"]["any"]
println(io, "FASTQC_URL=", fastqc["url"])
println(io, "FASTQC_SHA256=", fastqc["sha256"])
for tool in ("vsearch", "swarm")
archive = pins["tools"][tool]["archives"]["linux-x86_64"]
println(io, uppercase(tool), "_VERSION=", pins["tools"][tool]["version"])
println(io, uppercase(tool), "_URL=", archive["url"])
println(io, uppercase(tool), "_SHA256=", archive["sha256"])
end
end'
# R + required packages (needed by RCall)
# Install R directly via apt to avoid r-lib/actions/setup-r mangling
# R_HOME and stripping default packages (utils, methods, stats, etc.)
# The version is pinned to the apt revision named in the pin file, which tracks
# R.Version in renv.lock; CRAN's Ubuntu repository retains older revisions, so
# this resolves rather than merely requesting the newest.
- name: Set up R
run: bash scripts/ci/install-r-runtime.sh full
# The R packages are restored from renv.lock, the same pin a developer restores
# from, rather than resolved against whatever the repositories serve that day.
# Asking for a bare package name pins nothing: a Bioconductor release is frozen,
# so dada2 and its kin held still, but CRAN publishes only its newest revision,
# so vegan silently moved off the pinned 2.7-3 and test_provenance caught the
# drift. Restoring into .Library keeps the system library that the RCall steps
# and R_LIBS_SITE below expect, and renv is invoked explicitly here rather than
# left to activate itself. --no-init-file stops R sourcing the repository's
# .Rprofile, and with it the renv autoloader, which would otherwise rebind
# .Library to its own sandbox under root's cache; the flag is used rather than
# the environment variable above because sudo resets the environment first.
#
# renv's package cache persists across runs, so a restore that fails partway
# resumes from the packages it already built. The cache sits outside ~ because
# the restore runs as root. The run id makes each key unique so every attempt
# saves its progress; restore-keys picks the newest match.
- name: Restore the renv cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /opt/renv-cache
key: renv-${{ runner.os }}-R${{ env.R_APT_VERSION }}-${{ hashFiles('renv.lock') }}-${{ github.run_id }}
restore-keys: |
renv-${{ runner.os }}-R${{ env.R_APT_VERSION }}-${{ hashFiles('renv.lock') }}-
renv-${{ runner.os }}-R${{ env.R_APT_VERSION }}-
# R_CLI_DYNAMIC and TERM make renv print progress one line at a time in the log.
- name: Install R packages
env:
RENV_PATHS_CACHE: /opt/renv-cache
run: |
sudo mkdir -p "$RENV_PATHS_CACHE"
sudo chmod 777 "$RENV_PATHS_CACHE"
sudo env R_CLI_DYNAMIC=false TERM=dumb Rscript --no-init-file -e 'install.packages(c("renv", "BiocManager"), repos="https://cloud.r-project.org", lib=.Library)'
sudo env RENV_PATHS_CACHE="$RENV_PATHS_CACHE" R_CLI_DYNAMIC=false TERM=dumb Rscript --no-init-file -e 'renv::restore(project=".", library=.Library, prompt=FALSE)'
Rscript --no-init-file -e 'for (pkg in c("dada2","Biostrings","ShortRead","vegan","MASS","zCompositions","glmGamPoi")) if (!require(pkg,character.only=TRUE,quietly=TRUE)) stop(pkg, " failed to install")'
- name: Check DADA2 CSV assembly
run: Rscript --vanilla test/unit/test_dada2_tables.R
- name: Check null-calibration CSV handling
run: Rscript --vanilla test/unit/test_null_calibration_csv.R
- name: Check null-calibration failure annotations
run: python3 -m unittest discover -s scripts/tests -p 'test_null_calibration_failures.py' -v
# Saved on failure too, since the packages built before a failure are the ones
# the next attempt should reuse. Root writes the cache, so it is made readable first.
- name: Make the renv cache readable
if: always()
run: sudo chmod -R a+rX /opt/renv-cache || true
- name: Save the renv cache
if: always()
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /opt/renv-cache
key: renv-${{ runner.os }}-R${{ env.R_APT_VERSION }}-${{ hashFiles('renv.lock') }}-${{ github.run_id }}
- name: Install cutadapt
run: pip install "$CUTADAPT_SPEC"
# The one tool CI does not pin. Ubuntu ships no versioned cd-hit revision worth
# naming, and 24.04 is frozen, so its cd-hit does not move under us; the version
# that arrives is recorded at preflight, where a discrepancy is visible.
- name: Install cd-hit
run: sudo apt-get install -y cd-hit
# Downloaded against the pinned URL and refused unless it hashes to the pinned
# SHA256, so CI runs the same bytes install.jl puts on a developer's machine.
- name: Install vsearch
run: |
source scripts/ci/fetch_pinned.sh
fetch_pinned "$VSEARCH_URL" vsearch.tar.gz
echo "$VSEARCH_SHA256 vsearch.tar.gz" | sha256sum -c -
tar xzf vsearch.tar.gz --warning=no-unknown-keyword
sudo mv "vsearch-$VSEARCH_VERSION-linux-x86_64/bin/vsearch" /usr/local/bin/vsearch
rm -rf vsearch.tar.gz "vsearch-$VSEARCH_VERSION-linux-x86_64"
vsearch --version
- name: Install swarm
run: |
source scripts/ci/fetch_pinned.sh
fetch_pinned "$SWARM_URL" swarm.tar.gz
echo "$SWARM_SHA256 swarm.tar.gz" | sha256sum -c -
tar xzf swarm.tar.gz --warning=no-unknown-keyword
sudo mv "swarm-$SWARM_VERSION-linux-x86_64/bin/swarm" /usr/local/bin/swarm
rm -rf swarm.tar.gz "swarm-$SWARM_VERSION-linux-x86_64"
swarm --version
- name: Install multiqc
run: pip install "$MULTIQC_SPEC"
# FastQC ships as a Java launcher script; `fastqc --version` checks that Java
# is on PATH as well.
- name: Install fastqc
run: |
source scripts/ci/fetch_pinned.sh
fetch_pinned "$FASTQC_URL" fastqc.zip
echo "$FASTQC_SHA256 fastqc.zip" | sha256sum -c -
sudo unzip -q -d /opt fastqc.zip
rm -f fastqc.zip
sudo chmod +x /opt/FastQC/fastqc
sudo ln -sf /opt/FastQC/fastqc /usr/local/bin/fastqc
fastqc --version
- name: Instantiate Julia project
run: julia --project=. -e 'import Pkg; Pkg.instantiate()'
- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Build frontend
working-directory: frontend
run: |
bun install --frozen-lockfile --ignore-scripts
bun run build
- name: Audit frontend dependencies
working-directory: frontend
# Moderate advisories remain visible in `bun audit`; upgrading Router
# across a major version needs a separate routing/SSR review.
run: bun audit --audit-level high
- name: Frontend unit tests
working-directory: frontend
run: bun run test
- name: Download PR2 databases
run: julia --project=. config/ci/download_databases.jl
- name: Rebuild RCall against installed R
run: julia --project=. -e 'import Pkg; Pkg.build("RCall")'
- name: Verify R packages visible from RCall
run: |
julia --project=. -e '
using RCall
R"cat(.libPaths(), sep=\"\n\")"
for pkg in ["dada2", "vegan", "tibble", "MASS", "zCompositions", "glmGamPoi"]
R"if (!require($pkg, character.only=TRUE, quietly=TRUE)) stop($pkg, \" not found\")"
end
'
- name: Check independent high-precision references
run: python3 test/references/check_high_precision_references.py --check test/unit/test_high_precision.jl
- name: Run validated-statistics tests first
env:
R_LIBS_SITE: /usr/local/lib/R/site-library:/usr/lib/R/site-library:/usr/lib/R/library
shell: bash
run: |
set +e
julia --project=. -t2 --compiled-modules=no test/runtests.jl validated_statistics numeric_policy exact_summaries high_precision derived_analyses method_catalogue statistics_boundaries statistics_adversarial statistics_properties statistics_ui 2>&1 | tee /tmp/validated-statistics-tests.log
# Preserve both exits immediately: a successful Julia run must not
# hide a failed log write (e.g. a full runner filesystem).
statuses=("${PIPESTATUS[@]}")
status="${statuses[0]}"
if [ "$status" -eq 0 ]; then status="${statuses[1]}"; fi
if [ "$status" -ne 0 ]; then
enc() { head -c 3000 | sed -e 's/%/%25/g' -e 's/\r/%0D/g' | awk '{printf "%s%%0A", $0}'; }
# One annotation cannot hold a failing run, and a tail hides every
# failure but the last one. Publish a digest of all failing
# assertions first - the testset, the expression and the evaluated
# values are what identify the bug - then the end of the log.
# `set +e` above is deliberate: diagnostic helpers may find no
# matches, and only the captured test status controls this step.
# One annotation cannot hold a failing run, and a tail hides every
# failure but the last one. Pair each location with its assertion,
# cut long lines (an evaluated HTML page is useless in a digest),
# then publish the first few chunks.
# Print each failure's location and the lines that explain it: the
# expression and evaluated value for an assertion, the exception for
# an error. Long lines are cut because an evaluated HTML page would
# otherwise fill the whole digest.
awk '
/Test Failed at / { print; remaining = 2; next }
/Error During Test at / { print; remaining = 4; next }
/statistics UI request failed/ { print; remaining = 6; next }
remaining > 0 { print; remaining-- }
' /tmp/validated-statistics-tests.log |
cut -c1-260 > /tmp/validated-statistics-failures.log
rm -f /tmp/validated-statistics-digest-*
split -b 3800 /tmp/validated-statistics-failures.log /tmp/validated-statistics-digest-
index=0
for chunk in /tmp/validated-statistics-digest-*; do
[[ -e "$chunk" ]] || continue
index=$((index + 1))
[[ "$index" -le 8 ]] || break
echo "::error title=Validated-statistics failure digest $index::$(enc < "$chunk")"
done
echo "::error title=Validated-statistics log tail::$(tail -c 3000 /tmp/validated-statistics-tests.log | enc)"
fi
exit "$status"
- name: Run tests
env:
OCCUPANCY_RESULTS: ${{ runner.temp }}/occupancy-results.yml
CI_SKIP_TAXONOMY: "1"
R_LIBS_SITE: /usr/local/lib/R/site-library:/usr/lib/R/site-library:/usr/lib/R/library
# A failure also posts annotations, which the checks API serves without the full
# log. Annotations are capped at 4096 bytes, so each failing assertion gets its own
# (up to 8), followed by the summary rows that report a failure or an error.
shell: bash
run: |
command -v vsearch # The full suite must run the occupancy experiment, not skip it.
set +e
julia --project=. -t 2 --code-coverage=user --compiled-modules=no test/runtests.jl --integration --server 2>&1 | tee /tmp/julia-tests.log
# Preserve both exits immediately: a successful Julia run must not
# hide a failed log write (e.g. a full runner filesystem).
statuses=("${PIPESTATUS[@]}")
status="${statuses[0]}"
if [ "$status" -eq 0 ]; then status="${statuses[1]}"; fi
if [ "$status" -ne 0 ]; then
enc() { head -c 3500 | sed -e 's/%/%25/g' -e 's/\r/%0D/g' | awk '{printf "%s%%0A", $0}'; }
awk '
/Test Summary:/ { left = 0 }
/(Test Failed at|Error During Test at)/ || (left == 0 && /Got exception outside of a @test/) { n++; left = 60 }
left > 0 { print > ("/tmp/julia-failure-" n ".txt"); left-- }
' /tmp/julia-tests.log
shown=0
for f in $(ls /tmp/julia-failure-*.txt 2>/dev/null | sort -t- -k3 -n); do
shown=$((shown + 1))
[ "$shown" -gt 8 ] && break
echo "::error title=Julia test failure ${shown}::$(enc < "$f")"
done
awk '
/Test Summary:/ { print; p = index($0, "Pass"); pe = (p ? p + 3 : 0); t = index($0, "Total"); next }
pe && t && index($0, "|") && substr($0, pe + 1, t - pe - 1) ~ /[0-9]/ { print; next }
/ERROR:|Some tests did not pass/ { print }
' /tmp/julia-tests.log | tail -n 60 > /tmp/julia-tests-summary.txt
echo "::error title=Julia tests failed (summary)::$(enc < /tmp/julia-tests-summary.txt)"
if [ "$shown" -eq 0 ]; then
echo "::error title=Julia tests failed (tail)::$(tail -c 3500 /tmp/julia-tests.log | enc)"
fi
fi
exit "$status"
- name: Benchmark validated statistics
run: julia --project=. -t2 bench/validated_statistics.jl --output "$RUNNER_TEMP/validated-statistics-benchmark.json"
- name: Retain validated-statistics benchmark
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: validated-statistics-benchmark
path: ${{ runner.temp }}/validated-statistics-benchmark.json
if-no-files-found: ignore
retention-days: 30
- name: Validate measured occupancy grades
run: |
julia --project=. scripts/check-occupancy-grades.jl "$RUNNER_TEMP/occupancy-results.yml"
bash scripts/lint-occupancy-readings.sh
- name: Process coverage
run: julia scripts/process-ci-coverage.jl
- name: Upload coverage to Codecov
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
files: lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
slug: JoshuaJewell/MetaManifold-WebUI
fail_ci_if_error: false
# Issue #1 evidence that cannot live in the Julia suite: the two standards
# scripts run from the pinned standards commit, a seeded simulation study with
# pre-declared tolerances, and a mutation campaign that proves the focused
# tests can fail. The tools are pinned by commit and by SHA-256; a missing
# tool is exit 2 in both standards scripts, so this job cannot pass by skipping.
standards:
name: Statistics standards and evidence
needs: gate
if: needs.gate.outputs.relevant == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Julia
run: bash scripts/install-ci-julia.sh
# The versions, the HTTPS restriction and the exact-commit fetches live in
# the script (one reviewed place), not interpolated into this workflow.
- name: Install pinned standards tools
run: bash scripts/ci/fetch-statistics-tools.sh
# MetaManifold includes RCall at module load, and RCall's build step needs
# an R installation even though this job runs no R code. The version comes
# from the committed pin file, not from whatever apt calls current.
- name: Install the pinned R runtime
run: |
R_APT_VERSION="$(yq -r '.runtimes.r.apt_version' config/defaults/tool_versions.yml)"
R_APT_VERSION="$R_APT_VERSION" bash scripts/ci/install-r-runtime.sh core
# Null-calibration uncertainty is independently rechecked with base R,
# the same pinned runtime. This catches source/CLI regressions in the
# new replacement for the banned Python benchmark utility.
- name: Dataset-clustered calibration test (base R)
run: Rscript --vanilla bench/null_calibration/test_cluster_check.R
# The evidence scripts are ordinary project code: they load JSON3 and the
# statistics modules, so the environment must be instantiated after R.
- name: Instantiate Julia project
id: instantiate
run: julia --project=. -e 'import Pkg; Pkg.instantiate()'
# YAML-POLICY Y-3 / D307. --check exits 1 on drift and 2 when yq is
# missing or the file does not parse, so neither outcome can read as a pass.
- name: KYAML formatting gate
run: bash "$RUNNER_TEMP/standards/scripts/kyaml-format.sh" --check config/defaults/validated_statistics.kyaml
# JSON-POLICY J-1 / D306. Enforcement is scoped to the documents this
# branch is responsible for; the repository-wide pass is report-only
# because the policy calls unconverted files migration backlog, not a
# violation of a new change. Both outputs are retained.
- name: I-JSON/JCS gate
env:
IJSON_JCS: ${{ runner.temp }}/ijson-jcs/target/release/ijson-jcs
run: |
set -euo pipefail
bash "$RUNNER_TEMP/standards/scripts/check-ijson-jcs.sh" --enforce \
frontend/public/config.json
# test/fixtures/jcs_oracle.jsonl is a vector file: its inputs are
# deliberately NOT canonical and its expected outputs are the
# canonical form, so enforcing on it would be a category error.
bash "$RUNNER_TEMP/standards/scripts/check-ijson-jcs.sh" . \
| tee "$RUNNER_TEMP/ijson-jcs-report.txt"
# A failing study must say why in the checks API, not only in a log the
# reviewer cannot open from every client. Annotations are the same channel
# the Julia suite uses for failing assertions.
# run-with-log.sh keeps the command's own exit status and annotates a
# failure with the tail of its log. The workflow carries no copy of that
# logic, so the exit-preserving pipeline stays a single tested construct.
- name: Seeded simulation study
env:
RUN_LOG_TITLE: Seeded simulation study
run: |
bash scripts/ci/run-with-log.sh "$RUNNER_TEMP/simulation.log" -- \
julia --project=. -t2 scripts/statistics/simulation_study.jl \
--output "$RUNNER_TEMP/statistics-simulation-report.json"
# Six mutants are defined; four keep the campaign inside the job timeout.
# The script restores every file and re-verifies its digest, so a failed
# run cannot leave a planted defect behind.
#
# always(), but only once the environment exists. Run unconditionally, the
# campaign outlived an earlier failure and died with "Package JSON3 is
# required but does not seem to be installed: Run Pkg.instantiate()" — a
# second failure that named neither the real cause nor a mutable file, and
# masked the tool-install failure above it in the checks API. Gating on the
# instantiate step keeps the campaign's real value (it still runs when the
# study, the gates or an earlier evidence step failed) without inventing a
# failure that cannot distinguish a broken mutant from an absent project.
- name: Mutation campaign
if: always() && steps.instantiate.outcome == 'success'
env:
RUN_LOG_TITLE: Mutation campaign
run: |
bash scripts/ci/run-with-log.sh "$RUNNER_TEMP/mutation.log" -- \
julia --project=. -t2 scripts/statistics/mutation_check.jl --max-mutants 4 \
--output "$RUNNER_TEMP/statistics-mutation-report.json" \
--keep-logs "$RUNNER_TEMP/mutation-logs"
- name: Retain statistics evidence
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: statistics-standards
path: |
${{ runner.temp }}/statistics-simulation-report.json
${{ runner.temp }}/statistics-mutation-report.json
${{ runner.temp }}/ijson-jcs-report.txt
${{ runner.temp }}/mutation-logs
if-no-files-found: warn
retention-days: 30
# Machine-checked proofs of the Benjamini-Hochberg adjustment and the TSS/RLE
# scaling algebra (proofs/agda, proofs/agda/README.md).
# Agda publishes no 2.6.4.3 binary, and agda-stdlib 2.1 requires Agda 2.6.4.x,
# so the pair comes from Debian trixie, which packages exactly that pair with
# pre-built library interfaces. The image is pinned by digest and the two
# packages by revision, so a rebuild in the archive fails loudly here instead
# of silently changing the checker. These pins live here rather than in
# config/defaults/tool_versions.yml because no other job and no installer
# reads them.
agda:
name: Agda proofs
needs: gate
if: needs.gate.outputs.relevant == 'true'
runs-on: ubuntu-24.04
container: debian:trixie-20250929-slim@sha256:1caf1c703c8f7e15dcf2e7769b35000c764e6f50e4d7401c355fb0248f3ddfdb
timeout-minutes: 30
env:
DEBIAN_FRONTEND: noninteractive
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Agda and the standard library
run: |
apt-get update -qq
apt-get install -y --no-install-recommends agda-bin=2.6.4.3-1+b2 agda-stdlib=2.1-4
agda --version | grep -qx 'Agda version 2.6.4.3'
grep -qx 'name: standard-library-2.1' /usr/share/agda-stdlib/standard-library.agda-lib
# Runs before the type-check: --safe already refuses postulates, but the
# audit also refuses holes, check-disabling pragmas and flags, and any
# module that MetaManifold/All.agda does not import (an unimported module
# is never type-checked at all).
- name: Axiom audit
run: bash proofs/tests/axiom-audit.sh
# The invocation is the estate PROOF-POLICY §2.1 one, written out rather
# than left to the .agda-lib flags. Agda prints its warnings with
# Unicode, and under the image's default POSIX locale it then exits 42
# instead of reporting, so the locale is UTF-8.
- name: Type-check
env:
AGDA_DIR: /tmp/agda-dir
LC_ALL: C.UTF-8
run: |
mkdir -p "$AGDA_DIR"
echo /usr/share/agda-stdlib/standard-library.agda-lib > "$AGDA_DIR/libraries"
cd proofs/agda
if ! agda --safe --without-K --cubical-compatible MetaManifold/All.agda >/tmp/agda.log 2>&1; then
cat /tmp/agda.log
msg="$(awk 'NR > 1 { printf "%%0A" } { gsub(/%/, "%25"); printf "%s", $0 }' /tmp/agda.log | tail -c 3500)"
echo "::error file=proofs/agda/MetaManifold/All.agda,title=Agda type-check failed::$msg"
exit 1
fi
cat /tmp/agda.log
occupancy:
name: Occupancy typed peak
needs: occupancy_gate
if: needs.occupancy_gate.outputs.relevant == 'true'
runs-on: ubuntu-24.04
container: debian:trixie-20250929-slim@sha256:1caf1c703c8f7e15dcf2e7769b35000c764e6f50e4d7401c355fb0248f3ddfdb
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install proof tools
run: |
apt-get update -qq
apt-get install -y --no-install-recommends ca-certificates curl git agda-bin=2.6.4.3-1+b2 agda-stdlib=2.1-4
- name: Set up Julia
run: bash scripts/install-ci-julia.sh
- name: Check Session IR and occupancy proofs
run: bash scripts/check-occupancy.sh
# The full suite already runs the actual two-thread vsearch experiment. This
# required context reports that result, including the fresh grade validation,
# instead of paying for a second Julia/R installation and repeating the run.
disagreement:
name: Occupancy disagreement test
needs: [occupancy_gate, test]
if: always() && needs.occupancy_gate.result == 'success' && needs.occupancy_gate.outputs.relevant == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Require the executed experiment and grade validation to pass
env:
TEST_RESULT: ${{ needs.test.result }}
run: |
if [ "$TEST_RESULT" != success ]; then
echo "ERROR: full suite, occupancy experiment or measured-grade validation did not pass ($TEST_RESULT)" >&2
exit 1
fi
echo 'The full Julia suite passed, including the two-thread vsearch experiment and fresh empirical grade validation.' >> "$GITHUB_STEP_SUMMARY"