Skip to content

Commit 2268c15

Browse files
committed
ci: one build of mcpp per host, stages in one workflow, single-writer caches, timed shards and a coverage check
ci.yml classifies the change, runs the documentation checks, builds mcpp once per host (build.yml) and calls the per-area workflows, which take that build through use-built-mcpp instead of building their own. Caches are restored by every job and saved only by one job per key on a push to main; target/ is no longer cached. The e2e shards are assigned by measured duration (tests/e2e/timings) and write per-test reports that check_e2e_coverage.py reads: every test runs on some host, is run by a dedicated job, or is excused with a reason. run_all.sh grants llvm on Linux and probes musl and mingw-cross by family rather than by release.
1 parent f402012 commit 2268c15

27 files changed

Lines changed: 2382 additions & 643 deletions

‎.github/actions/bootstrap-mcpp/action.yml‎

Lines changed: 35 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
name: bootstrap-mcpp
22
description: >
3-
Restore the shared CI cache lineage (mcpp sandbox + xlings + target/) and
4-
bootstrap a released mcpp via xlings. Exports MCPP and XLINGS_BIN.
3+
Restore the shared CI cache lineage (mcpp sandbox and xlings) and bootstrap
4+
a released mcpp via xlings. Exports MCPP and XLINGS_BIN.
55
6-
Extracted so the split CI jobs (build / toolchain legs / e2e shards /
7-
integration) share ONE definition instead of copy-pasting a 40-line
8-
preamble per job. Every job that uses it lands on the same cache keys,
9-
which is what makes splitting cheap: each job restores a warm sandbox
10-
and only pays one incremental `mcpp build`.
6+
The caches are RESTORED here and never saved. One job per host writes them,
7+
the build job of .github/workflows/build.yml, and only on a push to main
8+
(rule R3 of .agents/docs/2026-10-02-pr-ci-acceleration-and-the-toolchain-
9+
specification-design.md). Before that rule every job that used this action
10+
saved the same key on success: thirty-four to forty-two saves per pull
11+
request against a 10 GB repository limit, parallel saves of one key racing
12+
each other, and the main lineage evicted within forty minutes (measured
13+
2026-10-01). The keys are outputs so that the one writer saves exactly what
14+
was restored.
15+
16+
`target/` is no longer cached. A restored `target/` made no build
17+
incremental: on an exact hit ninja still ran 830 of 830 edges, while the
18+
caches themselves were up to 3.3 GB each.
1119
1220
inputs:
1321
xlings-version:
@@ -26,10 +34,20 @@ inputs:
2634
# depended on the machine, which is why CI failed on `compat:lua` on
2735
# Windows and `mcpplibs.capi:lua` on Linux. Never pin below that.
2836
default: '2026.9.30.1'
29-
cache-target:
30-
description: also restore/save target/ (build artifacts + BMIs)
31-
required: false
32-
default: 'true'
37+
38+
outputs:
39+
sandbox-key:
40+
description: the exact key of the mcpp sandbox cache
41+
value: ${{ steps.sandbox.outputs.cache-primary-key }}
42+
sandbox-hit:
43+
description: "'true' when the sandbox was restored by its exact key"
44+
value: ${{ steps.sandbox.outputs.cache-hit }}
45+
xlings-key:
46+
description: the exact key of the xlings cache
47+
value: ${{ steps.xlings.outputs.cache-primary-key }}
48+
xlings-hit:
49+
description: "'true' when xlings was restored by its exact key"
50+
value: ${{ steps.xlings.outputs.cache-hit }}
3351

3452
runs:
3553
using: composite
@@ -38,8 +56,9 @@ runs:
3856
# "-release-" caches. A bare "mcpp-sandbox-<os>-" restore prefix used to
3957
# match the release sandbox too, silently swapping in a differently
4058
# populated registry (issue #120).
41-
- name: Cache mcpp sandbox
42-
uses: actions/cache@v4
59+
- name: Restore the mcpp sandbox
60+
id: sandbox
61+
uses: actions/cache/restore@v4
4362
with:
4463
path: ~/.mcpp
4564
# `runner.arch` IS PART OF EVERY KEY, AND WAS NOT.
@@ -70,8 +89,9 @@ runs:
7089
restore-keys: |
7190
mcpp-sandbox-${{ runner.os }}-${{ runner.arch }}-ci-xl${{ inputs.xlings-version }}-
7291
73-
- name: Cache xlings
74-
uses: actions/cache@v4
92+
- name: Restore xlings
93+
id: xlings
94+
uses: actions/cache/restore@v4
7595
with:
7696
path: ~/.xlings
7797
key: xlings-${{ runner.os }}-${{ runner.arch }}-v2-xl${{ inputs.xlings-version }}-${{ hashFiles('.xlings.json') }}
@@ -207,17 +227,3 @@ runs:
207227
# Precise key on src/ + manifest so a no-source-change run lands on a full
208228
# hit; layered restore-keys let partial hits keep BMI/dyndep state for a
209229
# proper incremental build.
210-
- name: Cache target/ (build artifacts + BMIs)
211-
if: inputs.cache-target == 'true'
212-
uses: actions/cache@v4
213-
with:
214-
path: target
215-
# `modules/**` belongs here as much as `src/**` does. mcpp's own
216-
# source lives in both since the subsystem split, and a key that hashed
217-
# only one of them would restore a target/ built from different sources
218-
# and report success — the failure mode a cache key exists to prevent,
219-
# arriving silently.
220-
key: mcpp-target-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ hashFiles('src/**', 'modules/**', 'tests/**', 'mcpp.toml', 'mcpp.lock') }}
221-
restore-keys: |
222-
mcpp-target-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-
223-
mcpp-target-${{ runner.os }}-${{ runner.arch }}-

‎.github/actions/setup-macos-llvm/action.yml‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,22 @@ inputs:
2424
required: false
2525
default: 'macos-15'
2626

27+
outputs:
28+
xlings-key:
29+
description: the exact key of the xlings cache
30+
value: ${{ steps.xlings.outputs.cache-primary-key }}
31+
xlings-hit:
32+
description: "'true' when xlings was restored by its exact key"
33+
value: ${{ steps.xlings.outputs.cache-hit }}
34+
2735
runs:
2836
using: composite
2937
steps:
30-
- name: Cache xlings
31-
uses: actions/cache@v4
38+
# Restored, never saved here: the macOS build job of
39+
# .github/workflows/build.yml is the one writer, on a push to main (rule R3).
40+
- name: Restore xlings
41+
id: xlings
42+
uses: actions/cache/restore@v4
3243
with:
3344
path: ~/.xlings
3445
key: xlings-${{ inputs.image }}-arm64-v3-xl${{ inputs.xlings-version }}-${{ hashFiles('.xlings.json') }}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: use-built-mcpp
2+
description: >
3+
Put this commit's mcpp, built once per host by .github/workflows/build.yml,
4+
in place of a build of the job's own (rule R1 of
5+
.agents/docs/2026-10-02-pr-ci-acceleration-and-the-toolchain-specification-design.md).
6+
7+
Run it after bootstrap-mcpp or setup-macos-llvm, which restore the sandbox
8+
and install the released bootstrap. It exports MCPP_BOOT (that bootstrap),
9+
MCPP and MCPP_FRESH (this commit's binary, at an absolute path), and
10+
MCPP_VENDORED_XLINGS, and sets the given mirror on xlings and on the binary.
11+
12+
The binary is the one the build job produced, not a repackaging of it, so
13+
every consumer tests what a self-host build makes. On Linux that binary's
14+
interpreter and runtime libraries live in payloads of the sandbox (glibc and
15+
the GCC runtime of the toolchain mcpp.toml names). A restored sandbox holds
16+
them; when it does not, the bootstrap installs that toolchain and the binary
17+
is run again. A binary that still does not run fails this step.
18+
19+
inputs:
20+
host:
21+
description: >
22+
The host the artifact was built on: linux-x86_64, linux-aarch64,
23+
macos-arm64 or windows-x86_64.
24+
required: true
25+
mirror:
26+
description: The mirror xlings and mcpp use in this job.
27+
required: false
28+
default: GLOBAL
29+
30+
runs:
31+
using: composite
32+
steps:
33+
- name: Download this commit's mcpp (${{ inputs.host }})
34+
uses: actions/download-artifact@v4
35+
with:
36+
name: mcpp-built-${{ inputs.host }}
37+
path: ${{ runner.temp }}/mcpp-built
38+
39+
- name: Use this commit's mcpp
40+
shell: bash
41+
run: bash "$GITHUB_ACTION_PATH/use.sh" "${{ inputs.host }}" "${{ inputs.mirror }}"
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
#!/usr/bin/env bash
2+
# The body of the use-built-mcpp action; see action.yml for what it provides.
3+
#
4+
# Usage: use.sh <host> <mirror>
5+
set -euo pipefail
6+
7+
host="$1"
8+
mirror="$2"
9+
10+
dir="$RUNNER_TEMP/mcpp-built"
11+
case "$host" in
12+
windows-*) exe=mcpp.exe; dir="$(cygpath -u "$dir")" ;;
13+
*) exe=mcpp ;;
14+
esac
15+
bin="$dir/$exe"
16+
if [ ! -f "$bin" ]; then
17+
echo "::error::the artifact mcpp-built-$host holds no $exe"
18+
ls -la "$dir" || true
19+
exit 1
20+
fi
21+
chmod +x "$bin"
22+
23+
boot="${MCPP:-}"
24+
if [ -z "$boot" ]; then
25+
echo "::error::MCPP is unset: run bootstrap-mcpp or setup-macos-llvm before use-built-mcpp"
26+
exit 1
27+
fi
28+
29+
# The toolchain mcpp.toml names for this host is the one the build used, so it
30+
# is the one whose payloads hold the binary's runtime.
31+
manifest_toolchain() {
32+
local key
33+
case "$host" in
34+
macos-*) key=macos ;;
35+
windows-*) key=windows ;;
36+
*) key=default ;;
37+
esac
38+
awk -v k="$key" '
39+
/^\[/ { in_tc = ($0 == "[toolchain]") ; next }
40+
in_tc && $1 == k { gsub(/"/, "", $3); print $3; exit }
41+
' mcpp.toml
42+
}
43+
44+
if ! out=$("$bin" --version 2>&1); then
45+
tc="$(manifest_toolchain)"
46+
echo "this commit's mcpp does not run yet ($out); installing ${tc:-the default toolchain} with the bootstrap"
47+
if [ -n "$tc" ]; then
48+
"$boot" toolchain install "${tc%@*}" "${tc#*@}"
49+
fi
50+
if ! out=$("$bin" --version 2>&1); then
51+
echo "::error::this commit's mcpp does not run on this runner: $out"
52+
exit 1
53+
fi
54+
fi
55+
echo "this commit's mcpp: $out ($bin)"
56+
57+
{
58+
echo "MCPP_BOOT=$boot"
59+
echo "MCPP=$bin"
60+
echo "MCPP_FRESH=$bin"
61+
if [ -n "${XLINGS_BIN:-}" ]; then echo "MCPP_VENDORED_XLINGS=$XLINGS_BIN"; fi
62+
} >> "$GITHUB_ENV"
63+
64+
if [ -n "${XLINGS_BIN:-}" ]; then
65+
"$XLINGS_BIN" config --mirror "$mirror" 2>/dev/null || true
66+
fi
67+
MCPP_VENDORED_XLINGS="${XLINGS_BIN:-}" "$bin" self config --mirror "$mirror"
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
#!/usr/bin/env python3
2+
"""Every e2e test runs somewhere, or says why it cannot (rule R5).
3+
4+
WHY THIS EXISTS
5+
6+
`run_all.sh` exits 0 on a skip, and a skip line reads the same whether the
7+
host legitimately lacks a capability or the runner was set up wrong. Measured
8+
on 2026-10-01, before this check: 24 of 564 tests ran on no host and were named
9+
by no workflow. Seven declared `llvm`, which no line granted; three declared
10+
`musl`, whose probe named a release (15.1.0) the runners no longer installed;
11+
seven declared `mingw-cross`, which no shard installed. Every one of those runs
12+
was green.
13+
14+
WHAT IT CHECKS
15+
16+
It reads the per-test reports the shards write (`E2E_REPORT` of run_all.sh, one
17+
`<status>\\t<test>\\t<ms>\\t<detail>` line per test) and decides, for every test
18+
under tests/e2e:
19+
20+
ran some report says pass, fail or timeout;
21+
job no report ran it, but a workflow names it (by file name, or by its
22+
number as an `E2E_ONLY` pattern such as `239_*.sh`): a dedicated job
23+
runs it and asserts its result itself;
24+
excused tests/e2e/coverage-exceptions.tsv lists it with the reason no hosted
25+
runner can run it;
26+
uncovered none of these. The check fails.
27+
28+
An exception for a test that ran, or for a test that does not exist, also fails
29+
the check, so that the list cannot outlive the reason it records.
30+
31+
With --timings-out DIR it writes `<host>.tsv`, the measured duration of every
32+
test that ran, merged across that host's shards, in the format run_all.sh reads
33+
from E2E_TIMINGS; refreshing tests/e2e/timings/ is copying those files. A report
34+
file is named `e2e-report-<host>-<shard>.tsv`.
35+
36+
Usage:
37+
check_e2e_coverage.py --reports DIR [--root DIR] [--timings-out DIR]
38+
"""
39+
from __future__ import annotations
40+
41+
import argparse
42+
import re
43+
import sys
44+
from collections import defaultdict
45+
from pathlib import Path
46+
47+
RAN = {"pass", "fail", "timeout"}
48+
REPORT_NAME = re.compile(r"e2e-report-(?P<host>[a-z0-9-]+?)-(?P<shard>\d+)\.tsv$")
49+
50+
51+
def read_reports(directory: Path):
52+
"""Yield (host, shard, status, test, ms, detail) for every report line."""
53+
for path in sorted(directory.rglob("e2e-report-*.tsv")):
54+
m = REPORT_NAME.search(path.name)
55+
if not m:
56+
continue
57+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
58+
parts = line.split("\t")
59+
if len(parts) < 3:
60+
continue
61+
ms = int(parts[2]) if parts[2].isdigit() else 0
62+
yield m["host"], int(m["shard"]), parts[0], parts[1], ms, parts[3] if len(parts) > 3 else ""
63+
64+
65+
def read_exceptions(path: Path) -> dict[str, str]:
66+
out: dict[str, str] = {}
67+
if not path.exists():
68+
return out
69+
for line in path.read_text(encoding="utf-8").splitlines():
70+
if not line.strip() or line.startswith("#"):
71+
continue
72+
test, _, reason = line.partition("\t")
73+
out[test.strip()] = reason.strip()
74+
return out
75+
76+
77+
def named_by_a_workflow(test: str, workflows: str) -> bool:
78+
number = test.split("_", 1)[0]
79+
return test in workflows or test[:-3] in workflows or f"{number}_*" in workflows
80+
81+
82+
def main() -> int:
83+
parser = argparse.ArgumentParser()
84+
parser.add_argument("--reports", required=True)
85+
parser.add_argument("--root", default=".")
86+
parser.add_argument("--timings-out")
87+
args = parser.parse_args()
88+
root = Path(args.root).resolve()
89+
90+
tests = sorted(p.name for p in (root / "tests" / "e2e").glob("[0-9]*.sh"))
91+
workflows = "\n".join(p.read_text(encoding="utf-8")
92+
for p in sorted((root / ".github" / "workflows").glob("*.yml")))
93+
exceptions = read_exceptions(root / "tests" / "e2e" / "coverage-exceptions.tsv")
94+
95+
ran: dict[str, set[str]] = defaultdict(set)
96+
skipped: dict[str, set[str]] = defaultdict(set)
97+
shard_ms: dict[tuple[str, int], int] = defaultdict(int)
98+
timings: dict[str, dict[str, int]] = defaultdict(dict)
99+
reports = 0
100+
for host, shard, status, test, ms, detail in read_reports(Path(args.reports)):
101+
reports += 1
102+
if status in RAN:
103+
ran[test].add(host)
104+
shard_ms[(host, shard)] += ms
105+
timings[host][test] = ms
106+
elif status == "skip":
107+
skipped[test].add(f"{host}: {detail}")
108+
if reports == 0:
109+
print(f"no report under {args.reports}: nothing can be said about coverage")
110+
return 1
111+
112+
uncovered, stale = [], []
113+
counts = defaultdict(int)
114+
for test in tests:
115+
if test in ran:
116+
counts["ran"] += 1
117+
if test in exceptions:
118+
stale.append(f"{test} is excused but ran on {', '.join(sorted(ran[test]))}")
119+
elif named_by_a_workflow(test, workflows):
120+
counts["job"] += 1
121+
elif test in exceptions:
122+
counts["excused"] += 1
123+
else:
124+
uncovered.append(f"{test}: {'; '.join(sorted(skipped[test])) or 'in no report'}")
125+
for test in exceptions:
126+
if test not in tests:
127+
stale.append(f"{test} is excused but does not exist")
128+
129+
print(f"{len(tests)} tests: {counts['ran']} ran on a shard, {counts['job']} run by a "
130+
f"dedicated job, {counts['excused']} excused, {len(uncovered)} uncovered")
131+
for (host, shard), ms in sorted(shard_ms.items()):
132+
print(f" {host} shard {shard}: {ms / 60000:.1f} min of tests")
133+
for line in uncovered:
134+
print(f"UNCOVERED: {line}")
135+
for line in stale:
136+
print(f"STALE EXCEPTION: {line}")
137+
138+
if args.timings_out:
139+
out = Path(args.timings_out)
140+
out.mkdir(parents=True, exist_ok=True)
141+
for host, table in timings.items():
142+
(out / f"{host}.tsv").write_text(
143+
"".join(f"{t}\t{ms}\n" for t, ms in sorted(table.items())), encoding="utf-8")
144+
return 1 if uncovered or stale else 0
145+
146+
147+
if __name__ == "__main__":
148+
sys.exit(main())

0 commit comments

Comments
 (0)