-
Notifications
You must be signed in to change notification settings - Fork 8
731 lines (713 loc) · 39.6 KB
/
Copy pathvalidate.yml
File metadata and controls
731 lines (713 loc) · 39.6 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
name: validate
on:
pull_request:
# mcpp.toml and index.toml carry the workspace member list, the inherited
# [indices] redirect and the client version floor — a change to any of them
# can break every member, so they gate the run like the descriptors do.
paths: ["pkgs/**/*.lua", "tests/**", "README.md", "README.zh-CN.md", "mcpp.toml", "index.toml", ".github/workflows/validate.yml"]
push:
branches: [main]
schedule:
# nightly full regression — exercises every workspace member regardless of diff
- cron: "0 6 * * *"
workflow_dispatch:
inputs:
cache:
description: "Package build cache — 'local' rebuilds every dependency per member, which is what the timing table should be read against when comparing"
type: choice
options: [global, local]
default: global
env:
# 2026.8.5.4 carries two things this workflow depends on:
# .5.1 `tools = [...]` — how a consumer asks for a dependency's
# `kind = "bin"` target, which tests/examples/protobuf-protoc is built
# on. Before it: "tools must be a string, inline dep table, or nested
# table".
# .5.4 windows links with lld. link.exe caps a response-file LINE at
# 128 KiB and opencv-module / opencv-module-dnn went past it —
# fatal error LNK1170: line in command file contains 135135 or
# more characters
# after 795s / 1166s of compiling. .5.3 newline-separated OUR response
# file, which was necessary but not sufficient: clang, acting as the
# driver, writes a SECOND one for the linker that we do not control.
# lld's response-file parser has no per-line limit at all.
# Together with re-enabling the global package cache below, this is
# what makes a green FULL run possible again: .5.3 removes the
# windows link failure, the cache removes the 150-minute timeout.
#
# Neither of them moves index.toml's min_mcpp: exposing compat.protobuf's `protoc`
# target is additive, and 2026.8.3.3 still parses that descriptor with an
# empty unknown_keys. The floor an index publishes decides whether older
# clients keep working at all (mcpp#349), so it moves only when a descriptor
# genuinely stops being readable — which is not the case here.
# 2026.8.3.1: on macOS, a global object that touches std::cout during static
# init crashes on sight (mcpp#336). Mach-O has no priority-ordered init
# section and libc++'s <iostream> carries no ios_base::Init guard of its own,
# so the streams are still all-zero when an archive member's initializer
# runs. mcpp now links a generated object FIRST whose constructor brings them
# up. It is not fixable package-side — std::ios_base::Init is only
# forward-declared in libc++'s <ios> — so boost-ext.ut genuinely requires
# this floor on macOS, and min_mcpp/latest_mcpp move with the pin as they
# always have (a consumer below the floor would get a segfault with no
# diagnostic, which is worse than E0006).
# 2026.8.3.3 is the pin rather than .3.1: .3.2/.3.3 are cross-compilation
# fixes (PE artifact naming, -static host-vs-target) that no leg of this
# matrix exercises, so taking the newest of the train costs nothing.
# 0.0.109: a bare dependency's wire address takes BOTH halves from the
# descriptor the identity gate accepted (mcpp#286). This is the client-side
# other half of the SPEC-001 migration below: mcpp used to take the NAME from
# the descriptor and the NAMESPACE from the request, so a bare `gtest =
# "1.15.2"` addressed `mcpplibs:gtest` — a key no index has. It only ever
# worked because the pre-migration literal `package.name` read
# "compat.gtest", which the hardcoded `compat.<short>` retry then caught.
# Short names removed that coincidence and left every bare request against
# this index broken on 0.0.108, which is why min_mcpp moves to 0.0.109.
# Note this index cannot cover that spelling itself: the `[indices]` redirect
# is keyed by the REQUEST's namespace, so a bare dependency resolves from the
# published remote index rather than the checkout under test. Bare-name
# resolution against a short-name index is upstream's e2e 165; what moving
# the floor buys here is that consumers of THIS index get a client that can
# address it.
# 0.0.106: SPEC-001 package identity (mcpp#280). `package.name` is a SINGLE
# ATOMIC SEGMENT — all hierarchy lives in `package.namespace` — and mcpp
# addresses a package by the LITERAL name it read, so descriptors no longer
# repeat their namespace inside `name`. This index is migrated to the short
# form, which is why min_mcpp/latest_mcpp move in lock-step: an older client
# re-derives `<ns>.<short>`, misses, and reports a bare E_NOT_FOUND. Bundles
# xlings 0.4.69, which keys its index by (namespace, name) so two packages
# sharing a short name in one index are both addressable (xlings#381) — this
# index now has three such pairs (imgui / ffmpeg / lua under compat vs the
# default namespace).
# 0.0.102: windows command-line ceiling (mcpp#261 — the clang scan rule got
# its P1689 JSON through shell redirection, which forced a `cmd /c` wrapper
# and with it cmd.exe's 8191-char limit; clang-scan-deps -o removes both, and
# $local_includes-carrying rules now fall back to response files). The pin
# matters here because a package consumed FROM the registry sits under a
# ~124-char xpkgs path instead of its own ~23-char checkout, which is what
# pushed the vendored-opencv scan command over the line. Also: purview-include
# depfile tracking extended to Clang (#257 — stale BMI reuse), OS-conditional
# `[build].flags` (#258), and per-OS splices keyed on the resolved target
# rather than the host (#254).
# 0.0.101: per-feature per-glob flags + per-OS features (mcpp#253) — what
# lets opencv select its dnn gemm backend per platform.
# 0.0.99: feature dep/feat forwarding (mcpp#243 — a feature can open a
# feature OF a dependency, e.g. opencv `dnn` forwarding compat.opencv/dnn);
# vendored xlings 0.4.67 for the >=2 index_repo install fix (mcpp#238 /
# openxlings/xlings#374); build.mcpp compiled program named `.exe` on
# Windows (mcpp#230 secondary surface, after the 0.0.96 scanner crash fix).
# 0.0.98: closes the obj-path disambiguation follow-ups that gated the
# source-build compat.opencv unification — #240 (link inputs now follow
# the disambiguated object names, so a dependency + consumer sharing a
# source basename like `src/main.cpp` no longer 'obj/main.o missing') and
# #239 (absolute/`..` dep-generated source paths sanitized component-wise
# so objects stay under obj/). Also: `MCPP_DEP_<NAME>_DIR` build.mcpp
# contract (#241), consumer-side `default-features = false` (#242), and a
# loud unknown-mcpp-key warning with did-you-mean (#237, replaces the
# silent-ignore at build time). Carried from 0.0.97: default-namespace
# index redirect (`[indices] default = { path }`), which turned the public
# module packages (imgui/ffmpeg/opencv/tinyhttps) into ordinary workspace
# members and retired the per-package reseeding smoke shells + their
# dedicated jobs; synchronous nasm bootstrap (mcpp#232 — the `mcpp index
# update` pre-step is gone), obj-path disambiguation (#233), spacey-defines
# quoting (#234), purview-include depfile tracking (#235). Older floors of
# note: 0.0.96 fixed the windows scanner symlink-escape crash (mcpp#230);
# 0.0.94 fixed feature-gated `sources` under `mcpp test` (mcpp#218); 0.0.91
# added standard = "c++fly" to the resolver grammar, so c++fly descriptors
# get the lint WARN below, not a hard grammar-parse rejection.
# 2026.8.6.1 是本次身份迁移的**前置**,不是顺手升级。
#
# `Fetcher::install_path(ns, shortName, version)` 的 legacy 扫描此前匹配任何以
# `-x-<shortName>` 结尾的目录、不看命名空间,于是查 `ocornut:imgui@1.92.8` 会拿到
# `compat-x-imgui/1.92.8` —— 另一个仅仅短名相同的包。它一直够不到,是因为 module
# 层用打包计数(imgui@0.0.6)而 compat 用上游版本(compat.imgui@1.92.8),版本永远
# 不撞;本次把 module 层对齐到上游之后它们重合了。mcpp#364 修掉了它。
#
# index.toml 的 min_mcpp **不动**。两个会撞的 compat 邻居都是 Form B,所以旧客户端
# 撞上时是响亮报错而不是静默用错包;下限是一道让整个索引对旧客户端失效的闸门
# (mcpp#349),只该在描述符真的读不动时抬。这里读得动,差的是解析得对。
MCPP_VERSION: "2026.8.6.2"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install lua
run: sudo apt-get install -y --no-install-recommends lua5.4
- name: Lint package descriptors
run: |
fail=0
for f in pkgs/*/*.lua; do
# 1. Lua syntax check — load (= compile) without executing.
# `loadfile(name, 't')` rejects bytecode and parses text only.
if ! lua5.4 -e "assert(loadfile('$f', 't'))" >/dev/null 2>&1; then
echo "::error file=$f::lua syntax error"
fail=1
fi
# 2. xpkg V1 baseline: the file has to populate `package = { ... }`
# with at least `spec`, `name`, and an `xpm` table. Form A vs
# Form B (mcpp = "<path>" / mcpp = { ... }) is descriptor-author
# choice and not enforced here.
for needle in 'spec *=' 'name *=' 'xpm *='; do
if ! grep -q "$needle" "$f"; then
echo "::error file=$f::missing required field ($needle)"
fail=1
fi
done
# 3. Package version identifiers and dependency versions should be
# bare versions ("1.2.3"), not upstream tag names ("v1.2.3").
# Download URLs may still contain refs/tags/v* when upstream
# uses that tag spelling.
if grep -nE '\["v[0-9]+|\["[^"]+"\][[:space:]]*=[[:space:]]*"v[0-9]+' "$f"; then
echo "::error file=$f::version identifiers must not use a leading v"
fail=1
fi
# 4. Mirror table sanity: when a download `url` is written as a
# { GLOBAL=..., CN=... } table, both regions must be present and
# the CN entry must point at the gitcode mcpp-res mirror.
if ! lua5.4 tests/check_mirror_urls.lua "$f"; then
fail=1
fi
# 5. `name` must be a SINGLE ATOMIC SEGMENT; hierarchy belongs in
# `namespace` (mcpp SPEC-001 §3.2). The legacy fully-qualified
# spelling stays accepted. Cheap second gate: it runs before the
# pinned mcpp is even downloaded, and mcpp >= 0.0.106 enforces
# the same rule inside `mcpp xpkg parse`.
if ! lua5.4 tests/check_package_name.lua "$f"; then
fail=1
fi
# 6. c++fly admission policy (mcpp design 2026-07-14 §11-Q2, v1):
# c++fly means "toolchain's latest level + every experimental
# gate" — deliberately toolchain-dependent, so a published
# package built with it is not reproducible for consumers.
# Policy: WARN (never fail) and observe ecosystem usage before
# deciding whether to tighten. Two spellings: `language = ` is
# the descriptor's inline mcpp-segment key; `standard = ` covers
# mcpp.toml content embedded in heredoc/generated_files blocks.
if grep -nE '\b(language|standard)[[:space:]]*=[[:space:]]*"c\+\+fly"' "$f" >/dev/null; then
echo "::warning file=$f::declares C++ standard \"c++fly\" (experimental playground mode) — toolchain-dependent and non-reproducible for consumers; published packages should pin a concrete standard (c++23/c++26)"
fi
done
[ $fail -eq 0 ] && echo "All package files valid."
exit $fail
# ── Whole-repository check (needs every descriptor at once) ──────
# An install() hook addressing a sibling package does so by
# `<namespace>:<literal package.name>`, and a miss returns nil rather
# than raising — so a stale spelling surfaces far downstream (a broken
# libxcb showed up as a libX11 link error). Verified across the repo
# because it needs the full set of declared identities.
- name: Lint cross-package references
run: lua5.4 tests/check_cross_package_refs.lua pkgs/*/*.lua
# ── Partial version bumps ────────────────────────────────────────
# A bump is a one-line-looking edit that has to land in N platform
# sections. Editing `xpm.linux` and reading the file back gives a file
# that CONTAINS the new version, so the author -- and any whole-file
# grep -- sees success, while the other platforms are left behind. The
# failure then surfaces as `<pkg>@<ver> not found` on a platform,
# against a file that literally contains that version string.
#
# Measured 2026-08-06: xpkg 0.0.52 and 0.0.53 were both added to
# `xpm.linux` alone; linux CI went green twice while macOS and Windows
# failed, and eight checks made from the outside all came back correct
# because each asked "is it in the index?" instead of "is it in THIS
# platform's section?". Whole-repo, because a partial bump is only
# visible by comparing sections against each other.
- name: Lint platform version parity
run: lua5.4 tests/check_platform_version_parity.lua pkgs/*/*.lua
# ── Single-source-of-truth grammar check ─────────────────────────
# `mcpp xpkg parse` uses EXACTLY the resolver's parser, so what
# passes here is what builds for users of the pinned MCPP_VERSION.
# Strict by default: unknown mcpp-segment keys fail (they would be
# silently ignored at build time). This also mechanically enforces
# the rollout rule "floor first, new grammar after": descriptors
# needing a newer grammar cannot pass a lint pinned to an older mcpp.
- name: Download pinned mcpp
run: |
curl -L -fsS -o mcpp.tar.gz \
"https://github.com/mcpp-community/mcpp/releases/download/v${MCPP_VERSION}/mcpp-${MCPP_VERSION}-linux-x86_64.tar.gz"
tar -xzf mcpp.tar.gz
echo "MCPP=$PWD/mcpp-${MCPP_VERSION}-linux-x86_64/bin/mcpp" >> "$GITHUB_ENV"
- name: Parse descriptors with the resolver grammar (mcpp xpkg parse)
run: |
fail=0
for f in pkgs/*/*.lua; do
if ! "$MCPP" xpkg parse "$f" > /dev/null; then
echo "::error file=$f::mcpp xpkg parse failed (resolver grammar)"
fail=1
fi
done
[ $fail -eq 0 ] && echo "All descriptors parse with mcpp ${MCPP_VERSION}."
exit $fail
mirror-cn-reachable:
# Closed-loop guard for the CN mirror: every CN url referenced by a
# descriptor must be a live, downloadable gitcode release asset.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install lua
run: sudo apt-get install -y --no-install-recommends lua5.4
- name: Check CN mirror assets are reachable
run: |
fail=0
# collect unique CN urls across all descriptors
: > /tmp/cn.tsv
for f in pkgs/*/*.lua; do
lua5.4 tests/list_cn_urls.lua "$f" >> /tmp/cn.tsv || true
done
sort -u /tmp/cn.tsv -o /tmp/cn.tsv
total=$(grep -c . /tmp/cn.tsv || true)
echo "checking $total CN mirror url(s)"
while IFS=$'\t' read -r url sha; do
[ -z "$url" ] && continue
# follow redirects; gitcode release assets resolve to object storage
code=$(curl -fsSL -o /dev/null -w '%{http_code}' --retry 2 --max-time 60 "$url" || echo "000")
if [ "$code" != "200" ]; then
echo "::error::CN mirror unreachable ($code): $url"
fail=1
else
echo "ok: $url"
fi
done < /tmp/cn.tsv
[ $fail -eq 0 ] && echo "All CN mirror urls reachable."
exit $fail
# ── The whole test surface, as a mcpp workspace ───────────────────────
# mcpp-index is a mcpp [workspace]; every per-library test project under
# tests/examples/ is a member. `mcpp test --workspace` builds + runs each
# member's tests/ (behavioral assertions) on each OS — members self-gate by
# `[target.'cfg(...)']` (e.g. the X11/glfw stack is linux-only, openblas is
# windows-only), so one command covers the matrix with no shell driver.
# The ~/.mcpp/registry cache carries the built compat packages (xpkgs) across
# runs, so repeat builds are fast.
#
# timeout-minutes is sized for the COLD build, not the cached path. The opencv
# module package carries a from-source OpenCV 5 build, and each feature variant
# re-keys the store into a full recompile, so a full run (forced whenever this
# workflow file changes — e.g. a version bump) serially builds three OpenCV
# variants on one runner: the opencv-module base member plus the `unifont` and
# `dnn` feature members. The registry cache (restore-keys prefix below)
# amortizes those across subsequent runs. 150 covers the one-time cold full
# build with headroom; it is a ceiling, not a target.
# ── The plan, computed ONCE ───────────────────────────────────────────
# Was inlined in every workspace job — three runners each re-deriving the
# same answer. It now also has to be decided BEFORE the matrix exists,
# because the matrix's shard dimension depends on it: a full run fans out,
# a selective one does not.
select:
runs-on: ubuntu-latest
outputs:
members: ${{ steps.fanout.outputs.members }}
matrix: ${{ steps.fanout.outputs.matrix }}
plan: ${{ steps.plan_shards.outputs.plan }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install lua
run: sudo apt-get install -y --no-install-recommends lua5.4
# ── Selective member testing ──────────────────────────────────────
# `mcpp test --workspace` builds every member (opencv, ffmpeg, …) and
# dominates CI wall-clock, while a PR almost always touches one
# package. Map changed files → affected members and test only those:
# pkgs/<x>/<lib>.lua → members whose mcpp.toml references <lib>
# tests/examples/<m>/** → member <m>
# Run the FULL workspace when the change can affect everything:
# non-PR events (push to main, the nightly cron, dispatch), this
# workflow file (it carries the mcpp version pins, so a version bump
# always re-validates every package), a non-member edit to the
# workspace manifest, or shared test scripts. Docs-only and tools/-only
# changes select nothing.
# Note: bash 3.2 on macOS runners — no associative arrays here.
- name: Select affected workspace members
id: plan
shell: bash
run: |
full() { echo "MEMBERS=__ALL__" >> "$GITHUB_ENV"; echo "full run: $1"; exit 0; }
[ "${{ github.event_name }}" = "pull_request" ] || full "event=${{ github.event_name }}"
base="origin/${{ github.base_ref }}"
changed=$(git diff --name-only "$base"...HEAD)
printf 'changed files vs %s:\n%s\n' "$base" "$changed"
sel=""
add() { case " $sel " in *" $1 "*) ;; *) sel="$sel $1" ;; esac; }
while IFS= read -r f; do
[ -n "$f" ] || continue
case "$f" in
.github/workflows/validate.yml|tests/*.sh) full "$f" ;;
mcpp.toml)
# Workspace manifest. Every new-package PR appends to the
# members list, so that alone must NOT force a full run:
# select the added members; anything else in this file
# (indices, settings) affects everyone → full.
if ! diff -q <(git show "$base:mcpp.toml" | grep -v 'tests/examples/') \
<(grep -v 'tests/examples/' mcpp.toml) >/dev/null; then
full "mcpp.toml non-member change"
fi
for p in $(comm -13 <(git show "$base:mcpp.toml" | grep -o 'tests/examples/[A-Za-z0-9._-]*' | sort -u) \
<(grep -o 'tests/examples/[A-Za-z0-9._-]*' mcpp.toml | sort -u)); do
add "${p#tests/examples/}"
done ;;
tests/examples/*)
m=${f#tests/examples/}; m=${m%%/*}
# A deleted/renamed member dir implies a mcpp.toml edit,
# which already forces a full run above.
[ -d "tests/examples/$m" ] && add "$m" ;;
pkgs/*.lua|pkgs/*/*.lua)
lib=$(basename "$f" .lua); lib=${lib#compat.}
hit=0
for mt in tests/examples/*/mcpp.toml; do
if grep -q "$lib" "$mt"; then add "$(basename "$(dirname "$mt")")"; hit=1; fi
done
[ "$hit" = 1 ] || echo "note: no workspace member exercises $f" ;;
# tools/ holds OFFLINE descriptor-generation and publishing
# helpers (tools/compat-*/, tools/gtc/, publish_mcpp_index.sh).
# Nothing under it is consumed by a package build: when one of
# them actually changes a package, the generated pkgs/*.lua
# changes with it and the rule above selects the right members.
# So a tools/ edit alone selects nothing rather than forcing a
# full workspace rebuild.
*.md|docs/*|.agents/*|.github/*|tools/*) : ;;
*) full "unclassified change: $f" ;;
esac
done <<EOF
$changed
EOF
sel=${sel# }
echo "MEMBERS=$sel" >> "$GITHUB_ENV"
echo "selected members: ${sel:-<none>}"
# Sharding is for the FULL run only, and the shard count per platform is
# that platform's RUNNER CONCURRENCY — not a round number.
#
# Measured on this repo (24 jobs queued, 6 running):
# macos 1 · linux 3 · windows 2
#
# That measurement is what makes over-sharding a real cost rather than a
# theoretical one: at concurrency 1, eight macOS shards run BACK TO BACK
# and each pays its own checkout + mcpp download + cache restore, so the
# split is strictly slower than not splitting. Wall-clock is
# ceil(shards / concurrency) x slowest-shard; shards beyond the
# concurrency only add fixed cost.
#
# Re-measure with:
# gh api repos/<owner>/<repo>/actions/runs/<id>/jobs --paginate \
# --jq '[.jobs[]|select(.status=="in_progress")]|length'
- name: Decide the fan-out
id: fanout
shell: bash
run: |
full=0; [ "$MEMBERS" = "__ALL__" ] && full=1
emit() { # platform os suffix ext mcpp xlings shards
for i in $(seq 0 $(( $7 - 1 ))); do
printf '{"platform":"%s","os":"%s","suffix":"%s","ext":"%s","mcpp":"%s","xlings":"%s","shard":%d,"shards":%d},' \
"$1" "$2" "$3" "$4" "$5" "$6" "$i" "$7"
done
}
if [ "$full" = 1 ]; then ln=3; mn=1; wn=2; else ln=1; mn=1; wn=1; fi
{
printf '{"include":['
emit linux ubuntu-latest linux-x86_64 tar.gz bin/mcpp registry/bin/xlings "$ln"
emit macos macos-15 macosx-arm64 tar.gz bin/mcpp registry/bin/xlings "$mn"
emit windows windows-latest windows-x86_64 zip bin/mcpp.exe registry/bin/xlings.exe "$wn"
printf ']}'
} | sed 's/,]}/]}/' > /tmp/matrix.json
echo "matrix=$(cat /tmp/matrix.json)" >> "$GITHUB_OUTPUT"
echo "members=$MEMBERS" >> "$GITHUB_OUTPUT"
cat /tmp/matrix.json
# The split is computed ONCE, here, and shipped to the runners as data.
# It used to run on each runner, which needed lua5.4 on all three
# platforms — windows has no apt or brew, and macOS's brew installs
# `lua`, not `lua5.4`, so every non-linux shard died with
# `lua5.4: command not found` after 16 seconds. Deciding once is also
# simply correct: one plan, not three runners each re-deriving it.
- name: Plan the shards
id: plan_shards
shell: bash
run: |
plan='${{ steps.fanout.outputs.members }}'
[ "$plan" = "__ALL__" ] && plan=""
{
printf '{'
first=1
for spec in linux:$(jq -r '[.include[]|select(.platform=="linux")]|length' /tmp/matrix.json) \
macos:$(jq -r '[.include[]|select(.platform=="macos")]|length' /tmp/matrix.json) \
windows:$(jq -r '[.include[]|select(.platform=="windows")]|length' /tmp/matrix.json); do
p=${spec%%:*}; n=${spec##*:}
[ "$first" = 1 ] || printf ','
first=0
printf '"%s":{' "$p"
for i in $(seq 0 $((n - 1))); do
[ "$i" = 0 ] || printf ','
m=$(lua5.4 tests/plan_shards.lua "$p" "$i" "$n" $plan)
printf '"%s":"%s"' "$i" "$m"
done
printf '}'
done
printf '}'
} > /tmp/plan.json
echo "plan=$(cat /tmp/plan.json)" >> "$GITHUB_OUTPUT"
jq . /tmp/plan.json
workspace:
# The shard suffix appears only when the platform is actually split.
name: workspace (${{ matrix.platform }}${{ matrix.shards == 1 && '' || format(' {0}/{1}', matrix.shard, matrix.shards) }})
needs: select
if: needs.select.outputs.members != ''
runs-on: ${{ matrix.os }}
# One shard is a fraction of the work, so this is a real ceiling rather
# than the thing that decides whether the job finishes (a full linux run
# used to hit 150 exactly and get cancelled).
timeout-minutes: 90
strategy:
fail-fast: false
# Whole matrix from `select`: the shard count is per-platform, because it
# tracks that platform's runner concurrency.
matrix: ${{ fromJSON(needs.select.outputs.matrix) }}
steps:
# Full history: the member-selection step below diffs against the PR
# base to decide which workspace members to test.
- uses: actions/checkout@v4
with:
fetch-depth: 0
# The cache key is computed ONCE, here, instead of inline in the cache
# step. `hashFiles()` globs the WORKING TREE, and actions/cache
# re-evaluates its `key` in the post (save) step — i.e. AFTER the build,
# when `tests/**` no longer matches 80-odd tracked sources but tens of
# thousands of build-output files under tests/examples/*/target and
# .mcpp (multi-GB; .gitignore does not apply to hashFiles). Hashing that
# tree blew past the runner's 120s template-evaluation cap on windows
# and failed an otherwise all-green job:
# "hashFiles('pkgs/**/*.lua, tests/**, .github/workflows/validate.yml')
# couldn't finish within 120 seconds"
# `git ls-files -s` reads the INDEX, so it sees exactly the tracked
# inputs, never build output, and reports blob SHAs git already has —
# no file content is read at all. Freezing the result in the job env
# also guarantees the save step keys on the same string the restore
# step used, no matter what the build left behind.
- name: Compute registry cache key
shell: bash
run: |
# git hash-object rather than sha256sum/cut: git is already a hard
# requirement here (checkout ran), coreutils on the windows leg is
# only a Git-Bash convenience.
h=$(git ls-files -s -- 'pkgs/**/*.lua' 'tests/**' '.github/workflows/validate.yml' \
| git hash-object --stdin)
echo "REGISTRY_CACHE_KEY=mcpp-registry-${{ runner.os }}-${{ env.MCPP_VERSION }}-$h" >> "$GITHUB_ENV"
- name: Restore mcpp registry cache
uses: actions/cache@v4
with:
# Holds toolchains AND the built compat packages (data/xpkgs), so a
# repeat `mcpp test` rebuilds little.
path: ~/.mcpp/registry
key: ${{ env.REGISTRY_CACHE_KEY }}
restore-keys: |
mcpp-registry-${{ runner.os }}-${{ env.MCPP_VERSION }}-
- name: Download mcpp
shell: bash
env:
MCPP_ARCHIVE: mcpp-${{ env.MCPP_VERSION }}-${{ matrix.suffix }}.${{ matrix.ext }}
MCPP_ROOT: mcpp-${{ env.MCPP_VERSION }}-${{ matrix.suffix }}
run: |
curl -L -fsS -o "$MCPP_ARCHIVE" \
"https://github.com/mcpp-community/mcpp/releases/download/v${MCPP_VERSION}/${MCPP_ARCHIVE}"
case "$MCPP_ARCHIVE" in
*.zip) powershell -NoProfile -Command "Expand-Archive -Force -Path '${MCPP_ARCHIVE}' -DestinationPath '.'" ;;
*) tar -xzf "$MCPP_ARCHIVE" ;;
esac
root="$PWD/$MCPP_ROOT"
mkdir -p "$HOME/.mcpp/registry"
cp -a "$root/registry/." "$HOME/.mcpp/registry/"
if [[ "$RUNNER_OS" == "Windows" ]]; then
echo "MCPP=$(cygpath -m "$root/${{ matrix.mcpp }}")" >> "$GITHUB_ENV"
echo "MCPP_VENDORED_XLINGS=$(cygpath -m "$root/${{ matrix.xlings }}")" >> "$GITHUB_ENV"
echo "$(cygpath -m "$root/bin")" >> "$GITHUB_PATH"
else
echo "MCPP=$root/${{ matrix.mcpp }}" >> "$GITHUB_ENV"
echo "MCPP_VENDORED_XLINGS=$root/${{ matrix.xlings }}" >> "$GITHUB_ENV"
echo "$root/bin" >> "$GITHUB_PATH"
fi
# compat.ffmpeg / compat.opencv5 carry NASM .asm sources. No host
# install and no index-refresh pre-step needed: mcpp >= 0.0.97
# resolves nasm itself through the same synchronous gate as the
# toolchain (index refresh + install + payload check BEFORE the build
# plans, mcpp#232). The sandbox copy lands in ~/.mcpp/registry, so
# the cache carries it across runs.
# ── This shard's slice of the plan ────────────────────────────────
# `select` decided WHAT runs; this decides which part of it runs HERE.
# Round-robin by position, which is what spreads the expensive members:
# opencv-module / -dnn / -unifont are adjacent in the list, so `% N`
# necessarily puts them on three different runners. A single job that
# builds all three spends 45+ minutes on opencv alone.
# ── This shard's slice ────────────────────────────────────────────
# Already decided by `select` (measured-time bin packing, see
# tests/plan_shards.lua). Arrives as data, so a runner needs no lua.
- name: Take this shard's members
shell: bash
run: |
mine='${{ fromJSON(needs.select.outputs.plan)[matrix.platform][format('{0}', matrix.shard)] }}'
echo "MEMBERS=$mine" >> "$GITHUB_ENV"
echo "shard ${{ matrix.shard }}/${{ matrix.shards }}: ${mine:-<none>}"
# ── Refresh the PUBLISHED index before testing ────────────────────
# Most members resolve everything from this checkout, but a member that
# redirects a namespace other than `compat` gets the REST from the
# published index — and nothing here ever refreshed it. The snapshot in
# play is whatever the pinned mcpp release vendored (the Download step
# `cp -a`s the release's registry/ over ~/.mcpp/registry, on top of the
# restored cache), so it is by construction older than main, and it
# never moves: the cache is saved with that same stale copy inside it.
#
# mcpp does refresh on a miss for a DIRECT dependency, which is why this
# went unnoticed — the gap is a Form-A package's TRANSITIVE dependency.
# tests/examples/godot-cpp-module hit it head-on: the module package's
# own compat.godot-cpp dep resolved against a snapshot predating the
# commit that added it, and failed with `index: local index <sha> (never
# refreshed)` even though the artifact had already been republished.
# (Older members never noticed: their compat packages have been in the
# index far longer than any snapshot.)
- name: Refresh the published package index
shell: bash
env:
MCPP_INDEX_MIRROR: GLOBAL
run: |
"$MCPP" index update
- name: mcpp test (workspace or affected members)
shell: bash
env:
MCPP_INDEX_MIRROR: GLOBAL
# The GLOBAL package build cache is on (mcpp >= 2026.7.30.2), which
# is the default — this step used to set `MCPP_BUILD_CACHE: local`
# and no longer does.
#
# That bypass existed for mcpp#344: object-path disambiguation fires
# on basename collisions across the WHOLE build dir — i.e. on what
# the CONSUMER pulls in — while the cache key covered only the
# dependency, so one entry could hold two layouts and ninja died at
# graph time with "missing and no known rule to make it". #344
# landed in 2026.8.3.4 with per-package Merkle keys that cover the
# consumer-dependent layout, so the reason is gone.
#
# Keeping it cost real time, and the full run is where it showed:
# with `local`, EVERY member recompiles EVERY dependency from
# scratch. 59 members that mostly share abseil / protobuf / opencv
# meant the same sources were built over and over —
#
# linux 2h30m -> cancelled at the 150-minute timeout
# windows 2h20m
# macos 1h26m
#
# — and a workspace cannot be validated by a job that cannot finish.
# With the cache on, a given (package, version, features, toolchain)
# is built once per run and every later member hits it.
run: |
"$MCPP" --version
# No `timeout` wrapper: absent on macOS runners; job-level timeout-minutes bounds it.
# One code path: the shard step above already expanded `__ALL__`
# into this runner's actual member names, so `mcpp test --workspace`
# — which would ignore the sharding and rebuild everything here — is
# gone.
#
# tests/run_members.sh is the SAME script you run locally. A timing
# table that only exists in CI cannot be used while deciding what to
# optimise, and a local harness that differs from CI measures
# something else.
if [ -z "$MEMBERS" ]; then
echo "No workspace member affected by this change — nothing to test."
else
MCPP_TIMINGS="$PWD/timings.tsv" bash tests/run_members.sh $MEMBERS
fi
# Per-shard timings, merged by the `timings` job below. `always()`: a
# run that failed is exactly when knowing where the time went matters.
- name: Upload this shard's timings
if: always() && hashFiles('timings.tsv') != ''
uses: actions/upload-artifact@v4
with:
name: timings-${{ matrix.platform }}-${{ matrix.shard }}
path: timings.tsv
retention-days: 14
# install()-driven packages (openssl, openblas) build through their own
# Make/Configure system, whose output xim's interface mode swallows; a
# failed hook surfaces only as `E_INTERNAL: [<pkg>] failed:`. Each writes
# a log into its install prefix, so on failure surface those — otherwise
# diagnosing a platform-specific build break costs a full CI round-trip
# per guess.
- name: Dump install() build logs on failure
if: failure()
shell: bash
run: |
found=0
while IFS= read -r log; do
found=1
echo "::group::$log"
tail -80 "$log"
echo "::endgroup::"
done < <(find tests/examples "$HOME/.mcpp/registry" -name 'mcpp_*_build.log' 2>/dev/null)
[ "$found" = 1 ] || echo "no install() build logs found"
# ── Where the time went ───────────────────────────────────────────────
# Sharding hides the cost: eight runners each report their own slice, and
# nobody can see which members actually dominate. This merges them into one
# ranking per platform, in the run summary, so the next optimisation starts
# from measurement instead of a guess.
#
# `always()` — a failed run is exactly when this is worth reading.
timings:
needs: [select, workspace]
if: always() && needs.select.outputs.members != ''
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
pattern: timings-*
path: timings
continue-on-error: true
- name: Rank members by wall-clock
shell: bash
run: |
shopt -s nullglob
files=(timings/*/timings.tsv)
if [ ${#files[@]} -eq 0 ]; then
echo "no timing data (every shard skipped or failed before testing)" \
>> "$GITHUB_STEP_SUMMARY"
exit 0
fi
# Artifact name carries the platform: timings-<platform>-<shard>.
for plat in linux macos windows; do
rows=$(mktemp)
for f in timings/timings-$plat-*/timings.tsv; do
[ -f "$f" ] && cat "$f" >> "$rows"
done
[ -s "$rows" ] || { rm -f "$rows"; continue; }
total=$(awk -F'\t' '{s += $1} END {print s+0}' "$rows")
count=$(wc -l < "$rows")
{
echo "### $plat — ${count} member(s), ${total}s of member wall-clock"
echo
echo "| rank | seconds | share | member | result |"
echo "|---:|---:|---:|---|---|"
sort -rn "$rows" | awk -F'\t' -v tot="$total" '
{ pct = tot > 0 ? ($1 * 100 / tot) : 0
printf "| %d | %s | %.1f%% | `%s` | %s |\n", NR, $1, pct, $2, $3 }'
echo
} >> "$GITHUB_STEP_SUMMARY"
rm -f "$rows"
done
echo "_Total is the SUM across shards; wall-clock is the slowest shard._" \
>> "$GITHUB_STEP_SUMMARY"
# The table that feeds the NEXT run's sharding. Emitted as an
# artifact rather than committed automatically: a number that
# rewrites itself on every run would make every diff noisy and would
# silently absorb a one-off slow runner. Refresh it deliberately —
# download this artifact and replace tests/member-timings.tsv when
# the numbers have actually moved.
{
echo "# <platform>\t<member>\t<seconds> — from run ${{ github.run_id }}"
echo "# refresh: download the member-timings artifact and replace this file"
for plat in linux macos windows; do
for f in timings/timings-$plat-*/timings.tsv; do
[ -f "$f" ] || continue
awk -F'\t' -v p="$plat" '{ printf "%s\t%s\t%s\n", p, $2, $1 }' "$f"
done
done
} | sort -u > member-timings.tsv
echo "wrote member-timings.tsv ($(grep -vc '^#' member-timings.tsv) rows)"
- name: Upload the timing table for the next run's sharding
if: always() && hashFiles('member-timings.tsv') != ''
uses: actions/upload-artifact@v4
with:
name: member-timings
path: member-timings.tsv
retention-days: 90