Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/drift-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,21 @@ jobs:
- name: Assert UBSan halts instead of recovering
run: bash scripts/check_sanitizer_can_fail.sh

dep-cache-selftest:
name: Dependency-cache self-test
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4

# A dependency cache that silently stops caching is indistinguishable
# from a working one -- the build still succeeds, it just clones again --
# so the four properties cmake/DepCache.cmake promises are asserted
# rather than assumed (morph#552). The first version of the helper used
# `CMakeLists.txt` as its validity marker, which a stylesheet repository
# does not have; this self-test is what caught it.
- name: Self-test the FetchContent dependency cache
run: bash scripts/test_dep_cache.sh

prose-lint:
name: Spec-citation, banned-terminology & CI-clang-pin lint
runs-on: ubuntu-24.04
Expand Down
4 changes: 4 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ set(MORPH_GLAZE_VERSION 7.4)
find_package(glaze ${MORPH_GLAZE_VERSION} CONFIG QUIET)
if(NOT glaze_FOUND)
include(FetchContent)
include(cmake/DepCache.cmake)
morph_cache_dep(glaze https://github.com/stephenberry/glaze.git v7.4.0)
FetchContent_Declare(
glaze
GIT_REPOSITORY https://github.com/stephenberry/glaze.git
Expand Down Expand Up @@ -550,6 +552,8 @@ if(MORPH_BUILD_TESTS)
find_package(Catch2 CONFIG QUIET)
if(NOT Catch2_FOUND)
include(FetchContent)
include(cmake/DepCache.cmake)
morph_cache_dep(Catch2 https://github.com/catchorg/Catch2.git v3.8.1)
FetchContent_Declare(
Catch2
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
Expand Down
116 changes: 116 additions & 0 deletions cmake/DepCache.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# ── A shared source cache for FetchContent dependencies (morph#552) ──────────
#
# Every configure in CI clones `glaze`, `Catch2`, `Lightweight` and
# `doxygen-awesome-css` again from github.com. One 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 -- and GitHub answers a throttled
# anonymous clone with 401, which makes git fall back to prompting for
# credentials and, with no TTY, fail 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. Measured on
# 2026-09-16 within a single run: 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, so not an outage but a
# threshold.
#
# `FETCHCONTENT_SOURCE_DIR_<NAME>` makes FetchContent use an existing tree and
# skip the download entirely. Pointing every configure at one cache directory
# therefore turns "a clone per configure" into "a clone per runner, once",
# which is the volume that trips the limit.
#
# Deliberately *not* `FETCHCONTENT_FULLY_DISCONNECTED`: a cache miss must fall
# back to cloning rather than fail the build. The cache is an optimisation, and
# an optimisation that can break a build is a liability.

# Where cached sources live. An explicit `MORPH_DEP_CACHE` wins; otherwise CI
# gets a default under the runner's home, which persists across jobs on a
# self-hosted runner. A local build gets nothing unless it opts in -- a
# developer's builds are not what exhausts a rate limit, and silently sharing
# sources between their checkouts would be a surprising thing to do.
if(DEFINED ENV{MORPH_DEP_CACHE})
set(MORPH_DEP_CACHE_DIR "$ENV{MORPH_DEP_CACHE}")
elseif(DEFINED ENV{CI} AND DEFINED ENV{HOME})
set(MORPH_DEP_CACHE_DIR "$ENV{HOME}/.cache/morph-dep-cache")
else()
set(MORPH_DEP_CACHE_DIR "")
endif()

# Points FetchContent at a cached checkout of @p name, populating the cache on
# first use. A no-op when no cache directory is configured, or when the caller
# already set FETCHCONTENT_SOURCE_DIR_<NAME> explicitly.
#
# `tag` is part of the directory name, so bumping a pin lands in a fresh
# directory instead of silently reusing the old revision -- the failure mode a
# cache keyed on name alone would have, and the one that is hardest to notice
# because everything still builds.
function(morph_cache_dep name repository tag)
if(MORPH_DEP_CACHE_DIR STREQUAL "")
return()
endif()
find_package(Git QUIET)
if(NOT Git_FOUND)
return() # FetchContent needs git too; let it produce the diagnostic
endif()
string(TOUPPER "${name}" _upper)
if(DEFINED FETCHCONTENT_SOURCE_DIR_${_upper} AND NOT FETCHCONTENT_SOURCE_DIR_${_upper} STREQUAL "")
return() # an explicit override wins, including the one CI may pass
endif()

string(SUBSTRING "${tag}" 0 16 _short_tag)
string(MAKE_C_IDENTIFIER "${name}-${_short_tag}" _slug)
set(_dir "${MORPH_DEP_CACHE_DIR}/${_slug}")
# An explicit sentinel, written only after the clone *and* the checkout
# succeeded, rather than probing for a file the dependency might not have.
# The first version of this used `CMakeLists.txt`, which is not present in
# every dependency -- `doxygen-awesome-css` is a stylesheet repository --
# so that entry would have been re-cloned on every configure while looking
# exactly like a working cache. It also distinguishes a complete entry from
# a tree left behind by an interrupted populate.
set(_stamp "${_dir}/.morph-dep-cache-ok")

if(NOT EXISTS "${_stamp}")
message(STATUS "morph: dep cache: populating ${name} (${tag}) at ${_dir}")
file(MAKE_DIRECTORY "${MORPH_DEP_CACHE_DIR}")
# Clone into a per-process staging path and rename into place, so two
# configures racing on the same runner cannot leave a half-written tree
# that later builds would treat as a valid cache entry. The rename is
# atomic within one filesystem; whichever loses the race just discards
# its own copy.
string(RANDOM LENGTH 12 _stage_id)
set(_staging "${_dir}.tmp.${_stage_id}")
file(REMOVE_RECURSE "${_staging}")
execute_process(
COMMAND ${GIT_EXECUTABLE} clone --quiet "${repository}" "${_staging}"
RESULT_VARIABLE _clone_result
ERROR_VARIABLE _clone_error)
if(NOT _clone_result EQUAL 0)
# Not fatal: FetchContent will do its own clone, exactly as before.
message(STATUS "morph: dep cache: could not pre-clone ${name} (${_clone_error}); "
"leaving it to FetchContent")
file(REMOVE_RECURSE "${_staging}")
return()
endif()
execute_process(
COMMAND ${GIT_EXECUTABLE} -C "${_staging}" checkout --quiet "${tag}"
RESULT_VARIABLE _checkout_result)
if(NOT _checkout_result EQUAL 0)
message(STATUS "morph: dep cache: ${tag} did not check out for ${name}; leaving it to FetchContent")
file(REMOVE_RECURSE "${_staging}")
return()
endif()
file(TOUCH "${_staging}/.morph-dep-cache-ok")
if(NOT EXISTS "${_stamp}")
file(REMOVE_RECURSE "${_dir}")
file(RENAME "${_staging}" "${_dir}" RESULT _rename_result)
endif()
file(REMOVE_RECURSE "${_staging}")
endif()

if(EXISTS "${_stamp}")
set(FETCHCONTENT_SOURCE_DIR_${_upper} "${_dir}" CACHE PATH
"Cached ${name} source tree (morph#552)" FORCE)
message(STATUS "morph: dep cache: ${name} from ${_dir}")
endif()
endfunction()
2 changes: 2 additions & 0 deletions docs/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ find_package(Doxygen REQUIRED)
message(STATUS "Doxygen found: ${DOXYGEN_EXECUTABLE}")

include(FetchContent)
include(${CMAKE_SOURCE_DIR}/cmake/DepCache.cmake)
morph_cache_dep(doxygen-awesome-css https://github.com/jothepro/doxygen-awesome-css.git v2.3.4)
FetchContent_Declare(
doxygen-awesome-css
GIT_REPOSITORY https://github.com/jothepro/doxygen-awesome-css.git
Expand Down
2 changes: 2 additions & 0 deletions examples/bank/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ set(LIGHTWEIGHT_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(LIGHTWEIGHT_BUILD_TOOLS OFF CACHE BOOL "" FORCE)
set(LIGHTWEIGHT_BUILD_BENCHMARK OFF CACHE BOOL "" FORCE)

include(${CMAKE_SOURCE_DIR}/cmake/DepCache.cmake)
morph_cache_dep(Lightweight https://github.com/LASTRADA-Software/Lightweight.git bbb972a78e1962b968a2c6ad93f7dade736eaa01)
FetchContent_Declare(Lightweight
GIT_REPOSITORY https://github.com/LASTRADA-Software/Lightweight.git
# Kept in sync with examples/common/CMakeLists.txt's identical pin --
Expand Down
2 changes: 2 additions & 0 deletions examples/common/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ set(LIGHTWEIGHT_BUILD_BENCHMARK OFF CACHE BOOL "" FORCE)
# the DLL export boundary (and its warnings) entirely instead of punching warning
# holes through every consumer target.
set(LIGHTWEIGHT_BUILD_SHARED OFF CACHE BOOL "" FORCE)
include(${CMAKE_SOURCE_DIR}/cmake/DepCache.cmake)
morph_cache_dep(Lightweight https://github.com/LASTRADA-Software/Lightweight.git bbb972a78e1962b968a2c6ad93f7dade736eaa01)
FetchContent_Declare(Lightweight
GIT_REPOSITORY https://github.com/LASTRADA-Software/Lightweight.git
# Pinned to master's tip commit, not the latest tag (v0.20260625.0) --
Expand Down
43 changes: 40 additions & 3 deletions scripts/check_coverage_roots.sh
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,29 @@ readonly profdata="${build_dir}/merged.profdata"
# symlink would report every file as foreign.
readonly source_root="$(pwd -P)"

# Third-party dependency sources are legitimately outside the checkout
# (morph#552). `cmake/DepCache.cmake` points FetchContent at a shared cache so a
# CI run clones once instead of a dozen times, and those trees then sit under
# the runner's home rather than under `build/*/_deps`, where they used to be
# only because FetchContent happened to put them there.
#
# They are dropped from the report either way -- coverage.sh filters to
# `include/morph` and the example rungs -- so their absence is intended, not the
# silence this gate exists to catch. What it is looking for is *morph's own*
# sources arriving from a foreign worktree, and that hazard is untouched by
# this: a foreign worktree is not the dependency cache.
#
# Resolved exactly as DepCache.cmake resolves it, so the two cannot drift into
# disagreeing about where the cache is.
if [ -n "${MORPH_DEP_CACHE:-}" ]; then
dep_cache_root="${MORPH_DEP_CACHE}"
elif [ -n "${CI:-}" ] && [ -n "${HOME:-}" ]; then
dep_cache_root="${HOME}/.cache/morph-dep-cache"
else
dep_cache_root=""
fi
readonly dep_cache_root

if [ -n "$export_json_file" ]; then
export_json="$(cat "$export_json_file")"
else
Expand Down Expand Up @@ -139,8 +162,17 @@ printf '%s' "$export_json" | python3 -c '
import json, os, sys

source_root = os.path.realpath(sys.argv[1]) + os.sep
dep_cache_arg = sys.argv[2] if len(sys.argv) > 2 else ""
dep_cache_root = (os.path.realpath(dep_cache_arg) + os.sep) if dep_cache_arg else ""
document = json.load(sys.stdin)

def is_allowed(name):
if name.startswith(source_root):
return True
# Narrow on purpose: this admits the dependency cache and nothing else, so
# a source from any other foreign root still fails.
return bool(dep_cache_root) and name.startswith(dep_cache_root)

filenames = [f["filename"] for export in document["data"] for f in export["files"]]
if not filenames:
print("check_coverage_roots: the coverage mapping names no files at all.",
Expand All @@ -150,7 +182,7 @@ if not filenames:
print(" to find, committed by the detector (morph#426).", file=sys.stderr)
raise SystemExit(1)

foreign = sorted({f for f in filenames if not f.startswith(source_root)})
foreign = sorted({f for f in filenames if not is_allowed(f)})
if foreign:
print("check_coverage_roots: %d of %d files in the coverage mapping are not"
% (len(foreign), len(filenames)), file=sys.stderr)
Expand All @@ -167,5 +199,10 @@ if foreign:
print(" -DUSE_COMPILER_CACHE=OFF, or delete the build tree and rebuild.", file=sys.stderr)
raise SystemExit(1)

print("check_coverage_roots: %d files, all under the checkout." % len(filenames))
' "$source_root"
cached = sum(1 for f in filenames if not f.startswith(source_root))
if cached:
print("check_coverage_roots: %d files, %d under the checkout and %d in the dependency cache."
% (len(filenames), len(filenames) - cached, cached))
else:
print("check_coverage_roots: %d files, all under the checkout." % len(filenames))
' "$source_root" "$dep_cache_root"
35 changes: 35 additions & 0 deletions scripts/test_check_coverage_roots.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
# Asserts five directions:
#
# 1. every file under the checkout -> pass
# 1b. a file in the configured dependency cache -> pass (morph#552)
# 1c. a foreign worktree, cache also configured -> still fail
# 2. one file under another worktree -> fail, naming it
# 3. a sibling directory sharing the root's prefix -> fail
# 4. a mapping naming no files at all -> fail, not a vacuous pass
Expand Down Expand Up @@ -99,6 +101,39 @@ else
fi
fi

# 2b. The dependency cache is admitted (morph#552): its trees are third-party
# sources that coverage.sh filters out anyway, and they live outside the
# checkout only because DepCache.cmake shares them across a run's dozen
# configures instead of re-cloning each time.
write_export "$tmp/depcache.json" \
"${repo_root}/include/morph/core/bridge.hpp" \
"$tmp/dep-cache/glaze_v7_4_0/include/glaze/glaze.hpp"
if MORPH_DEP_CACHE="$tmp/dep-cache" run_checker "$tmp/nonexistent-build" "$tmp/depcache.json" > "$tmp/2b.out" 2>&1; then
note "ok 2b: a file in the configured dependency cache passes"
else
fail "2b: a file in the dependency cache was rejected"
cat "$tmp/2b.out" >&2
fi

# 2c. The load-bearing half of 2b. Admitting the dependency cache must not
# admit *anything* outside the checkout, or this gate would have been widened
# into uselessness -- which is the failure mode it was written to catch, turned
# on itself. A foreign worktree must still fail while a cache is configured.
write_export "$tmp/depcache-and-foreign.json" \
"${repo_root}/include/morph/core/bridge.hpp" \
"$tmp/dep-cache/glaze_v7_4_0/include/glaze/glaze.hpp" \
"/home/somebody/repo/morph-wt/999/examples/crm/src/models/account_model.cpp"
if MORPH_DEP_CACHE="$tmp/dep-cache" run_checker "$tmp/nonexistent-build" \
"$tmp/depcache-and-foreign.json" > "$tmp/2c.out" 2>&1; then
fail "2c: with a dependency cache configured, a foreign worktree was accepted too"
cat "$tmp/2c.out" >&2
elif grep -q "morph-wt/999" "$tmp/2c.out"; then
note "ok 2c: a foreign worktree still fails while the dependency cache is configured"
else
fail "2c: rejected, but did not name the foreign path"
cat "$tmp/2c.out" >&2
fi

# 3. A sibling whose path shares the checkout's prefix as a string.
write_export "$tmp/sibling.json" \
"${repo_root}/include/morph/core/bridge.hpp" \
Expand Down
Loading
Loading