ci: cache FetchContent sources so a run clones once, not a dozen times - #556
Merged
Merged
Conversation
Closes morph#552.
Every configure clones `glaze`, `Catch2`, `Lightweight` and
`doxygen-awesome-css` again. One CI run configures more than a dozen times, so
a single push produces dozens of anonymous clones from the self-hosted fleet's
shared egress address. GitHub answers a throttled anonymous clone with 401,
git falls back to prompting for credentials, and with no TTY that surfaces as
fatal: could not read Username for 'https://github.com': No such device or address
which reads like an auth misconfiguration and is not one.
Evidence the issue did not have. Within a single run on 2026-09-16, six
self-hosted clones succeeded between 20:12 and 20:14, then every job starting
20:19-20:22 failed this way -- the same five runners and the same egress
address. Not an outage: a threshold. The volume is the lever, so this reduces
it rather than retrying into it.
`FETCHCONTENT_SOURCE_DIR_<NAME>` makes FetchContent use an existing tree and
skip the download entirely. `cmake/DepCache.cmake` points every declaration at
one cache directory, populating it on first use, which turns "a clone per
configure" into "a clone per runner, once".
Placed in CMake rather than in ci.yml on purpose. There are twelve configure
sites in ci.yml and three more workflows that run cmake; the four
`FetchContent_Declare` calls are one place. Editing twelve call sites to pass
flags would be churn with twelve chances to miss one -- and a missed one is
invisible, because it still builds.
Deliberately not `FETCHCONTENT_FULLY_DISCONNECTED`: a cache miss falls back to
cloning. An optimisation that can break a build is a liability, so the failure
path is a STATUS message and an unset variable, not an error.
Inert unless configured. `MORPH_DEP_CACHE` wins if set; otherwise CI gets a
default under the runner's home, which persists across jobs on a self-hosted
runner. A local build gets nothing: a developer's builds are not what exhausts
a rate limit, and silently sharing source trees between their checkouts would
be a surprising thing to do.
scripts/test_dep_cache.sh asserts the four properties, because a cache that
silently stops caching is indistinguishable from a working one -- the build
still succeeds, it just clones again. It earned its keep immediately: the first
version used `CMakeLists.txt` as its validity marker, which
`doxygen-awesome-css` does not have, so that entry would have been re-cloned on
every configure while looking exactly like a working cache. The marker is now
an explicit sentinel written only after both the clone and the checkout
succeed, which also distinguishes a complete entry from a tree left by an
interrupted populate.
Verified: cold configure populates once with zero FetchContent clones; a second
configure from a different build directory reuses it with zero clones and zero
populates; an unconfigured build produces no dep-cache activity at all; and an
unreachable repository leaves the configure standing with the variable unset.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Yaraslaut
force-pushed
the
ci/fetchcontent-dep-cache
branch
from
September 17, 2026 07:21
5e352f7 to
b2bcd9c
Compare
…catches a foreign worktree Moving the FetchContent sources out of `build/*/_deps` put them outside the checkout, and `check_coverage_roots.sh` fails on any coverage record that is not under it -- so the clang-coverage leg went red on 56 Lightweight headers. The gate was doing its job; its rule had simply been an over-approximation that happened to hold. Third-party dependency sources being outside the checkout is normal. They were only ever inside because FetchContent put them in the build directory, and coverage.sh drops them either way -- it filters to `include/morph` and the example rungs -- so their absence from the report is intended rather than the silence this gate exists to catch. What it is actually looking for is *morph's own* sources arriving from a foreign worktree through a path-independent compiler cache (morph#426), and a dependency cache is not a foreign worktree. The widening is deliberately narrow: the one cache directory, resolved exactly as DepCache.cmake resolves it so the two cannot drift apart about where it is, and nothing else. Every other foreign root still fails. That "and nothing else" is the part worth testing, so the self-test asserts both directions: a file in the configured cache passes, *and* a foreign worktree still fails while a cache is configured. Without the second, widening this gate would be indistinguishable from blinding it -- which is precisely the failure mode the gate was written to catch, turned on itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #552.
The failure
Jobs on the self-hosted fleet fail at Configure, before anything is built:
That reads like an auth misconfiguration and is not one. GitHub answers a throttled anonymous clone with
401; git falls back to prompting for credentials; with no TTY it dies like this.Evidence the issue did not have
Within a single run on 2026-09-16, six self-hosted clones succeeded between 20:12 and 20:14 — then every job starting 20:19–20:22 failed this way. Same five runners, same egress address, no blanket outage. That is a threshold being crossed, not a network fault, which makes clone volume the lever.
Every configure clones all four dependencies again, and one run configures more than a dozen times. So a single push produces dozens of anonymous clones from one IP.
The fix
FETCHCONTENT_SOURCE_DIR_<NAME>makes FetchContent use an existing tree and skip the download entirely.cmake/DepCache.cmakepoints every declaration at one cache directory and populates it on first use — turning a clone per configure into a clone per runner, once.Why CMake and not
ci.yml. There are twelve configure sites inci.ymland three more workflows running cmake; the fourFetchContent_Declarecalls are one place. Editing twelve call sites to pass flags is churn with twelve chances to miss one — and a missed one is invisible, because it still builds, just slowly.Why not
FETCHCONTENT_FULLY_DISCONNECTED. A cache miss falls back to cloning. An optimisation that can break a build is a liability, so the failure path is aSTATUSmessage and an unset variable, never an error.Inert unless configured.
MORPH_DEP_CACHEwins if set; otherwise CI gets a default under the runner's home, which persists across jobs on a self-hosted runner. A local build gets nothing — a developer's builds are not what exhausts a rate limit, and silently sharing source trees between their checkouts would be surprising.The self-test earned its keep immediately
scripts/test_dep_cache.shasserts four properties, because a cache that silently stops caching is indistinguishable from a working one — the build still succeeds, it just clones again.It caught a real bug on its first run: the helper used
CMakeLists.txtas its validity marker, whichdoxygen-awesome-cssdoes not have. That entry would have been re-cloned on every configure while looking exactly like a working cache. The marker is now an explicit sentinel written only after both the clone and the checkout succeed, which also distinguishes a complete entry from a tree left behind by an interrupted populate.Verified
The tag is part of the cache directory name, so bumping a pin lands in a fresh directory rather than silently reusing the old revision — the failure mode a name-only key would have, and the hardest to notice because everything still builds.
🤖 Generated with Claude Code