Skip to content

ci: cache FetchContent sources so a run clones once, not a dozen times - #556

Merged
Yaraslaut merged 2 commits into
masterfrom
ci/fetchcontent-dep-cache
Sep 17, 2026
Merged

Yaraslaut merged 2 commits into
masterfrom
ci/fetchcontent-dep-cache

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Closes #552.

The failure

Jobs on the self-hosted fleet fail at Configure, before anything is built:

fatal: could not read Username for 'https://github.com': No such device or address
Failed to clone repository: 'https://github.com/stephenberry/glaze.git'

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.cmake points 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 in ci.yml and three more workflows running cmake; the four FetchContent_Declare calls 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 a STATUS message and an unset variable, never 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 surprising.

The self-test earned its keep immediately

scripts/test_dep_cache.sh asserts 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.txt as its validity marker, which doxygen-awesome-css does 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

Scenario Result
Cold cache, real project populates once, 0 FetchContent clones
Warm cache, different build dir and build type 0 populates, 0 clones
Unconfigured (local dev) no dep-cache activity at all
Unreachable repository configure survives, variable left unset

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

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
Yaraslaut force-pushed the ci/fetchcontent-dep-cache branch from 5e352f7 to b2bcd9c Compare September 17, 2026 07:21
…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

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Yaraslaut
Yaraslaut merged commit df2bdb5 into master Sep 17, 2026
51 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CI: self-hosted runners intermittently fail FetchContent clones with "could not read Username for 'https://github.com'"

1 participant