Skip to content

Commit cd8dad9

Browse files
committed
Repairs from the review of #759: whole-token coverage, a classifier that falls back to the whole CI, a concurrency group per push to main, cache keys that name the install list, a step for the shard's installs, and a record that leaves a tab-holding root unrecorded
1 parent 7e88d01 commit cd8dad9

10 files changed

Lines changed: 139 additions & 42 deletions

File tree

‎.agents/docs/2026-10-02-pr-ci-acceleration-and-the-toolchain-specification-design.md‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -421,8 +421,13 @@ repository has paid for that before.
421421
are the main cause of F3. They return only if the build becomes incremental across checkouts. That needs
422422
a measured cause (Part VII) and a fix that keeps the stale-object hazards already recorded in
423423
`.agents/docs/2026-05-15-stdcompat-restat-e2e.md` and `cross-build-test.yml:416-434` out of the result.
424-
- **Concurrency.** `cancel-in-progress` becomes `${{ github.event_name == 'pull_request' }}`. A push to
425-
`main` runs to completion and writes its caches; a superseded pull-request run is still cancelled.
424+
- **Concurrency.** `cancel-in-progress` becomes `${{ github.event_name == 'pull_request' }}`, and a push
425+
to `main` has a group of its own commit. A push to `main` runs to completion and writes its caches; a
426+
superseded pull-request run is still cancelled. One group for all pushes to `main` would not be enough,
427+
because GitHub keeps one pending run per group and cancels the older pending run when another arrives.
428+
- **Keys name what fills the cache.** The sandbox key hashes `ci.yml` as well as `mcpp.toml` and
429+
`.xlings.json`, because `ci.yml` lists what the build job installs before it saves. A cache saved under
430+
an unchanged key is never saved again, so a toolchain added to that list would otherwise never reach it.
426431
- **Wine.** The Wine packages come from one pinned archive, published once as a release asset of this
427432
repository, instead of from the `apt` mirrors with a cache that eviction removes (F7). The archive's
428433
sha256 is checked before installation.
@@ -620,6 +625,18 @@ reading taken while building it.
620625
machine that has them and failed on every runner, and nothing noticed, because
621626
no runner ran it. The fixture's root is now a library, which is what the test
622627
says it is: "this test compiles only".
628+
- **The review of the change** (a code review of mcpp#759) found eight defects. Each was repaired before
629+
merging:
630+
- the coverage check matched test names inside comments and inside longer names, and now matches whole
631+
tokens outside comments;
632+
- `changes` failed, rather than running the whole CI, when the GitHub API did not answer;
633+
- pushes to `main` shared a concurrency group;
634+
- the cache keys did not name the install list;
635+
- the Linux shards' toolchain installs shared the suite's time limit, and now have a step of their own;
636+
- the `macos-27` label's effect was undocumented (it is read on the next push);
637+
- the CHANGELOG gave three Linux shards;
638+
- a path-dependency root with a tab in its path could be cut short in the build record, and is now left
639+
unrecorded, which declines the fast path.
623640
- **Coverage found more than F9.** Classifying the tests of the 2026-10-01
624641
logs found 24 that ran on no runner and were named by no workflow. The
625642
seven `llvm` tests are among them, and so are three `musl` tests: the probe

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,10 @@ runs:
8585
# sandbox — which is what actually resolves dependencies — would
8686
# silently stay behind (observed: a 0.4.30 sandbox surviving under a
8787
# 0.4.69 bootstrap for weeks).
88-
key: mcpp-sandbox-${{ runner.os }}-${{ runner.arch }}-ci-xl${{ inputs.xlings-version }}-${{ hashFiles('mcpp.toml', '.xlings.json') }}
88+
# ci.yml is part of the key because it names the toolchains the build
89+
# job installs before it saves (`prewarm`): a sandbox saved under an
90+
# unchanged key would never gain one added there.
91+
key: mcpp-sandbox-${{ runner.os }}-${{ runner.arch }}-ci-xl${{ inputs.xlings-version }}-${{ hashFiles('mcpp.toml', '.xlings.json', '.github/workflows/ci.yml') }}
8992
restore-keys: |
9093
mcpp-sandbox-${{ runner.os }}-${{ runner.arch }}-ci-xl${{ inputs.xlings-version }}-
9194

‎.github/tools/check_e2e_coverage.py‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@
1818
under tests/e2e:
1919
2020
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;
21+
job no report ran it, but a workflow names it outside a comment, as a
22+
whole token: by file name, by name without `.sh`, or by its number
23+
as an `E2E_ONLY` pattern such as `239_*.sh`. A dedicated job runs it
24+
and asserts its result itself. A name in a comment does not count,
25+
and neither does a longer name that contains it;
2426
excused tests/e2e/coverage-exceptions.tsv lists it with the reason no hosted
2527
runner can run it;
2628
uncovered none of these. The check fails.
@@ -74,9 +76,25 @@ def read_exceptions(path: Path) -> dict[str, str]:
7476
return out
7577

7678

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
79+
TOKEN = re.compile(r"(?<![A-Za-z0-9_.-])(\d+[a-z]?_(?:\*|[A-Za-z0-9_]+))(?:\.sh)?(?![A-Za-z0-9_])")
80+
81+
82+
def workflow_tokens(texts: list[str]) -> set[str]:
83+
"""Every test name or `<number>_*` pattern named outside a comment."""
84+
tokens: set[str] = set()
85+
for text in texts:
86+
for line in text.splitlines():
87+
code = line.split("#", 1)[0] if line.lstrip().startswith("#") else line
88+
if not code.strip():
89+
continue
90+
tokens.update(m.group(1) for m in TOKEN.finditer(code))
91+
return tokens
92+
93+
94+
def named_by_a_workflow(test: str, tokens: set[str]) -> bool:
95+
stem = test[:-3]
96+
number = stem.split("_", 1)[0]
97+
return stem in tokens or f"{number}_*" in tokens
8098

8199

82100
def main() -> int:
@@ -88,8 +106,8 @@ def main() -> int:
88106
root = Path(args.root).resolve()
89107

90108
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")))
109+
workflows = workflow_tokens([p.read_text(encoding="utf-8")
110+
for p in sorted((root / ".github" / "workflows").glob("*.yml"))])
93111
exceptions = read_exceptions(root / "tests" / "e2e" / "coverage-exceptions.tsv")
94112

95113
ran: dict[str, set[str]] = defaultdict(set)

‎.github/workflows/ci-linux-e2e.yml‎

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -48,23 +48,12 @@ jobs:
4848
with:
4949
host: linux-x86_64
5050

51-
- name: E2E suite
52-
# About twice the shard's budget of ten minutes (R4): reached only by a
53-
# hang. The per-test 600 s limit in run_all.sh names the test that hung.
54-
timeout-minutes: 22
51+
# Its own step and its own limit, so that downloads on a cold sandbox are
52+
# not taken from the suite's budget.
53+
- name: The toolchains the suite probes
54+
timeout-minutes: 15
5555
run: |
5656
set -euo pipefail
57-
# MCPP is this commit's binary and MCPP_BOOT the released bootstrap
58-
# (use-built-mcpp). e2e 252 needs the latter: the claim that an older
59-
# client can build against a package this commit produces is only
60-
# worth making against a real old binary.
61-
export MCPP MCPP_BOOT
62-
test -x "$MCPP_VENDORED_XLINGS"
63-
# GitHub-hosted runners are outside CN; keep CI toolchain downloads on
64-
# the global mirror while mcpp's default remains CN for fresh local
65-
# sandboxes. E2E tests with their own MCPP_HOME read this variable.
66-
export MCPP_E2E_TOOLCHAIN_MIRROR=GLOBAL
67-
"$MCPP" self config
6857
# Pin the global default so test 28 (default-toolchain path) gets a
6958
# deterministic GNU answer instead of an auto-install pick. Installed
7059
# explicitly, not assumed: the restored sandbox is the build job's,
@@ -81,6 +70,24 @@ jobs:
8170
"$MCPP" toolchain install llvm 22.1.8
8271
"$MCPP" toolchain install mingw-cross 16.1.0
8372
XLINGS_HOME="$MCPP_HOME/registry" "$MCPP_VENDORED_XLINGS" install xim:nasm -y
73+
74+
- name: E2E suite
75+
# About twice the shard's budget of ten minutes (R4): reached only by a
76+
# hang. The per-test 600 s limit in run_all.sh names the test that hung.
77+
timeout-minutes: 22
78+
run: |
79+
set -euo pipefail
80+
# MCPP is this commit's binary and MCPP_BOOT the released bootstrap
81+
# (use-built-mcpp). e2e 252 needs the latter: the claim that an older
82+
# client can build against a package this commit produces is only
83+
# worth making against a real old binary.
84+
export MCPP MCPP_BOOT
85+
test -x "$MCPP_VENDORED_XLINGS"
86+
# GitHub-hosted runners are outside CN; keep CI toolchain downloads on
87+
# the global mirror while mcpp's default remains CN for fresh local
88+
# sandboxes. E2E tests with their own MCPP_HOME read this variable.
89+
export MCPP_E2E_TOOLCHAIN_MIRROR=GLOBAL
90+
"$MCPP" self config
8491
bash tests/e2e/run_all.sh
8592
8693
# One file, no glob: the report the e2e-coverage job of ci.yml reads.
@@ -346,7 +353,7 @@ jobs:
346353
uses: actions/cache/restore@v4
347354
with:
348355
path: ~/.mcpp
349-
key: mcpp-hermetic-${{ hashFiles('mcpp.toml') }}
356+
key: mcpp-hermetic-${{ hashFiles('mcpp.toml', '.github/workflows/ci-linux-e2e.yml') }}
350357
restore-keys: |
351358
mcpp-hermetic-
352359

‎.github/workflows/ci.yml‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@ on:
2727
branches: [ main ]
2828
workflow_dispatch:
2929

30-
# A superseded pull-request run is cancelled. A push to main runs to the end,
31-
# because its build jobs are the only writers of the caches (R3), and a
32-
# cancelled run saves nothing.
30+
# A superseded pull-request run is cancelled. Every push to main runs to the
31+
# end, in a group of its own commit: its build jobs are the only writers of the
32+
# caches (R3), and a cancelled run saves nothing. A group shared by all pushes
33+
# to main would still lose runs, because GitHub keeps one pending run per group
34+
# and cancels the older pending one when a third arrives.
3335
concurrency:
34-
group: ci-${{ github.ref }}
36+
group: ci-${{ github.event_name == 'push' && github.sha || github.ref }}
3537
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
3638

3739
permissions:
@@ -61,15 +63,19 @@ jobs:
6163
run: |
6264
set -euo pipefail
6365
full() { echo "code=true" >> "$GITHUB_OUTPUT"; echo "code=true ($1)"; }
66+
# Anything this step cannot list runs the whole CI: an API error, a
67+
# force-push whose previous commit is gone, an event without a diff.
6468
case "$EVENT" in
6569
pull_request)
6670
gh api --paginate "repos/$REPO/pulls/$PR/files" \
67-
--jq '.[] | .filename, (.previous_filename // empty)' > changed.txt ;;
71+
--jq '.[] | .filename, (.previous_filename // empty)' > changed.txt \
72+
|| { full "the files of the pull request could not be listed"; exit 0; } ;;
6873
push)
6974
if [ -z "$BEFORE" ] || [ "$BEFORE" = 0000000000000000000000000000000000000000 ]; then
7075
full "a push with no previous commit"; exit 0
7176
fi
72-
gh api "repos/$REPO/compare/$BEFORE...$SHA" > compare.json
77+
gh api "repos/$REPO/compare/$BEFORE...$SHA" > compare.json \
78+
|| { full "the push could not be compared with its previous commit"; exit 0; }
7379
# The compare API lists at most 300 files; a longer list is
7480
# treated as a change of everything.
7581
if [ "$(jq '.files | length' compare.json)" -ge 300 ]; then
@@ -187,7 +193,10 @@ jobs:
187193
uses: ./.github/workflows/ci-macos.yml
188194
with:
189195
# R7: the known-red legs (#669) run where they can change a decision: on
190-
# main, on dispatch, and on a pull request labelled `macos-27`.
196+
# main, on dispatch, and on a pull request labelled `macos-27`. The label
197+
# is read when the pull request is pushed to; adding it starts no run,
198+
# because a run on every label of every pull request would cost a whole
199+
# CI each time.
191200
known-red: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'macos-27') }}
192201

193202
macos-e2e:

‎.github/workflows/cross-build-test.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ jobs:
8080
uses: actions/cache/restore@v4
8181
with:
8282
path: ~/.mcpp
83-
key: mcpp-sandbox-${{ runner.os }}-cross-${{ matrix.target }}-${{ hashFiles('mcpp.toml', '.xlings.json') }}
83+
key: mcpp-sandbox-${{ runner.os }}-cross-${{ matrix.target }}-${{ hashFiles('mcpp.toml', '.xlings.json', '.github/workflows/cross-build-test.yml') }}
8484
restore-keys: |
8585
mcpp-sandbox-${{ runner.os }}-cross-${{ matrix.target }}-
8686
@@ -244,7 +244,7 @@ jobs:
244244
uses: actions/cache/restore@v4
245245
with:
246246
path: ~/.mcpp
247-
key: mcpp-sandbox-${{ runner.os }}-mingw-cross-${{ hashFiles('mcpp.toml', '.xlings.json') }}
247+
key: mcpp-sandbox-${{ runner.os }}-mingw-cross-${{ hashFiles('mcpp.toml', '.xlings.json', '.github/workflows/cross-build-test.yml') }}
248248
restore-keys: |
249249
mcpp-sandbox-${{ runner.os }}-mingw-cross-
250250

‎CHANGELOG.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ No default toolchain changes.
5757
- **One writer per cache.** Every job restores. One job per key saves, on a
5858
push to `main` only, and `target/` is no longer cached, because a restored
5959
`target/` made no build incremental.
60-
- **E2E shards by measured duration**, three on Linux, three on Windows and two
60+
- **E2E shards by measured duration**, four on Linux, three on Windows and two
6161
on macOS, from `tests/e2e/timings/`. `run_all.sh` takes `E2E_TIMINGS`,
6262
`E2E_REPORT` and `E2E_LIST`.
6363
- **Every e2e test runs somewhere.** The `e2e-coverage` job fails when a test
@@ -66,7 +66,7 @@ No default toolchain changes.
6666
`run_all.sh` grants `llvm` on Linux, and it probes `musl` and `mingw-cross`
6767
by family rather than by one release.
6868
- The legs that are known red (#669) run on `main`, on dispatch, and on a pull
69-
request labelled `macos-27`.
69+
request labelled `macos-27` (the label is read on the next push).
7070

7171
## [2026.10.1.3] - 2026-10-01
7272

‎src/build/execute.cppm‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -667,10 +667,21 @@ void write_build_cache_entries(const std::filesystem::path& path,
667667
<< '\n';
668668
f << "profile=" << e.profile << '\n';
669669
f << "cacheMode=" << e.cacheMode << '\n';
670-
f << "depSources=" << e.depSourceRoots.size() << '\n';
671-
for (auto& r : e.depSourceRoots)
672-
f << r.root.generic_string() << '\t' << join_extensions(r.moduleExtensions)
673-
<< '\t' << join_extensions(r.deviceExtensions) << '\n';
670+
// A root whose path holds a tab or a line break cannot be written in
671+
// this line format: read back, the path would be cut at the tab, and a
672+
// directory that happened to exist under the shorter name would be
673+
// swept with the wrong table. Such an entry records no roots, which
674+
// reads as "predates the list" and declines the fast path, the safe
675+
// direction.
676+
const bool writable = std::ranges::none_of(e.depSourceRoots, [](auto const& r) {
677+
return r.root.generic_string().find_first_of("\t\n\r") != std::string::npos;
678+
});
679+
if (writable) {
680+
f << "depSources=" << e.depSourceRoots.size() << '\n';
681+
for (auto& r : e.depSourceRoots)
682+
f << r.root.generic_string() << '\t' << join_extensions(r.moduleExtensions)
683+
<< '\t' << join_extensions(r.deviceExtensions) << '\n';
684+
}
674685
f << "runner=" << (e.runnerDeclared ? 1 : 0) << '\n';
675686
f << "runtier=" << (e.runTierPending ? 1 : 0) << '\n';
676687
f << "features=" << e.features << '\n';

‎tests/scripts/test_check_e2e_coverage.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,21 @@ def test_ran_named_and_excused_tests_are_covered(self) -> None:
4848
self.assertEqual(r.returncode, 0, r.stdout)
4949
self.assertIn("1 ran on a shard, 2 run by a dedicated job, 1 excused, 0 uncovered", r.stdout)
5050

51+
def test_a_name_in_a_comment_or_inside_a_longer_name_does_not_count(self) -> None:
52+
e2e = self.root / "tests" / "e2e"
53+
(e2e / "60_commented.sh").write_text("# x\n# requires: llvm\n")
54+
(e2e / "70_short.sh").write_text("# x\n# requires: llvm\n")
55+
with (self.root / ".github" / "workflows" / "ci.yml").open("a") as f:
56+
f.write(" # 60_commented.sh is mentioned in a comment only\n"
57+
"run: bash tests/e2e/170_short_but_longer.sh\n")
58+
(self.reports / "e2e-report-linux-2.tsv").write_text(
59+
"skip\t60_commented.sh\t0\tmissing capability: llvm\n"
60+
"skip\t70_short.sh\t0\tmissing capability: llvm\n")
61+
r = self.run_check()
62+
self.assertEqual(r.returncode, 1)
63+
self.assertIn("UNCOVERED: 60_commented.sh", r.stdout)
64+
self.assertIn("UNCOVERED: 70_short.sh", r.stdout)
65+
5166
def test_a_test_that_runs_nowhere_fails(self) -> None:
5267
(self.root / "tests" / "e2e" / "50_nowhere.sh").write_text("# x\n# requires: llvm\n")
5368
(self.reports / "e2e-report-linux-2.tsv").write_text(

‎tests/unit/test_build_cache_record.cpp‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,23 @@ TEST(BuildCacheRecord, TheEngineAndEachRootsTablesSurviveAWriteAndARead) {
150150
EXPECT_EQ(read[0].toolchainRequest, e.toolchainRequest);
151151
}
152152

153+
// A path the line format cannot hold is not written at all, so the entry reads
154+
// as one that predates the list and declines, rather than as a shorter path.
155+
TEST(BuildCacheRecord, ARootWithATabInItsPathIsLeftUnrecorded) {
156+
Tmp tmp;
157+
auto e = minimal_entry();
158+
e.depSourceRoots = {{"/work/a\tb", {".ixx"}, {}}};
159+
e.depSourceRootsRecorded = true;
160+
e.toolchainRecorded = true;
161+
e.toolchainRequest = "cli=;default=gcc@16.1.0";
162+
write_build_cache_entries(tmp.path / "target" / ".build_cache", {e});
163+
const auto read = read_build_cache(tmp.path);
164+
ASSERT_EQ(read.size(), 1u);
165+
EXPECT_FALSE(read[0].depSourceRootsRecorded);
166+
EXPECT_TRUE(read[0].depSourceRoots.empty());
167+
EXPECT_TRUE(read[0].toolchainRecorded);
168+
}
169+
153170
TEST(BuildCacheRecord, AnEmptyListOfRootsIsRecordedNotAbsent) {
154171
Tmp tmp;
155172
auto e = minimal_entry();

0 commit comments

Comments
 (0)