Build engineer comparing cached and approved CMake library paths and reviewing validator logs on desktop monitors.

CMake Found a Library. Did It Run Your Validator?

Fri, Oct 16, 2026

CMake Found a Library. Did It Run Your Validator?

A configure can succeed, find_library() can return an absolute path, the target can link, and the executable can run while the selected dependency no longer satisfies the approval rule you intended to enforce. That is the operational trap this article isolates.

The decisive question is not “did CMake find a library?” It is whether the library selected by this configure passed its current approval rule. CMake’s versioned find_library() documentation explains that a previously successful result can suppress searching and that reuse of a cached result bypasses VALIDATOR. NO_CACHE changes storage for a new search result; it does not invalidate an existing successful result. VALIDATOR was added in CMake 3.25, and NO_CACHE in 3.21. Those are feature-history statements, not claims about the latest CMake release.

To make the distinction visible, the laboratory below uses two tiny owned static archives with the same filename, libprobe.a. Library A returns 11; library B returns 22. The marker is deliberately primitive: it tells us which owned archive reached the executable. It is a provenance sentinel, not an ABI test, security scanner, or authenticity mechanism.

The complete driver printed below was run with CMake 3.31.6 in the owned Linux laboratory described here. Its literal oracle was satisfied: validator calls [true,false,false,false,true,true], configure outcomes [true,true,true,false,true,true], and runtime markers [11,11,11,not-run,22,22]. These are results from that execution, not an extension of a preparatory probe’s evidence. Caching itself is not the problem. A cached path can remain acceptable when the current rule independently approves it. A returned cached path, however, is not evidence that the current validator ran.

Define the current library approval rule

Start with policy, not with CMake state. In this fixture there are exactly two approval policies:

Policy

Search root

Exact approved file

Expected marker

A

A/

canonical path of A/libprobe.a

11

B

B/

canonical path of B/libprobe.a

22

Approval here means equality of exact canonical file paths. It is not “anything under a trusted-looking prefix,” and it is not inferred from PROBE_LIBRARY. The approved path enters configure separately as APPROVED_LIBRARY; find_library() supplies the selected path. For existing files, the file(REAL_PATH) contract resolves absolute paths and symbolic links. Comparing those resulting names implements this fixture’s policy. It is a pathname comparison, not an inode comparison or a content-authentication mechanism.

That separation matters because a test can otherwise become self-fulfilling. If CI says “the approved library is whatever PROBE_LIBRARY already says,” then a stale cache can define both the observation and the expected answer. The gate will always agree with itself. The approval manifest must instead be independent of the result under test.

The executable marker is a second, deliberately narrow observation. Marker 11 means the linked probe implementation returns 11; marker 22 means it returns 22. Canonical-path equality and the marker agreeing are enough for this laboratory’s selection decision, but they prove much less than a production dependency qualification. They do not prove who produced the archive, whether it is malicious, whether it is ABI-compatible with every caller, whether every symbol behaves correctly, or whether the file changed after configure time.

Keep both owned archives unchanged across the six runs. The markers distinguish these two implementations only because the fixture constructs them with known, different return values. Two unrelated libraries could return the same number. Likewise, different hard-link names can refer to the same underlying object without satisfying this exact-name policy. Neither condition is an authenticity test, and neither is exercised as an additional experiment here.

This is also a different failure class from header dependency validation in Make. That adjacent workflow asks whether an incremental compile reflected changed source inputs. Here, the sources are unchanged; the approval policy around a library lookup changes while configure state persists.

From a principal build-and-release engineering perspective, this is the first release-gate rule to write down: selection evidence, approval evidence, and functional evidence are different records. A successful tool return cannot silently stand in for all three.

Create an isolated configure and build environment

The laboratory owns one disposable root containing A/, B/, src/, reuse/, and fresh/. reuse/ intentionally accumulates configure history across C1 through C5. fresh/ is separate and used only for C6. The probe-library lookup excludes default system locations. The compiler and executable still use the host toolchain and C runtime; this is not a hermetic build. No dependency download or package-selection workflow participates.

The CMake project declares cmake_minimum_required(VERSION 3.25) because that is the feature minimum for the VALIDATOR syntax used here. The actual observed runtime is separate evidence: the complete driver ran with CMake 3.31.6. The compiler identity captured by the run was cc (Debian 14.2.0-19) 14.2.0, and the selected generator was Unix Makefiles. These observations describe this run only; they are not claims that those are the latest versions or that other toolchains have been tested.

Use an owned Linux environment with Bash, cc, ar, CMake, and make already installed. The driver records their identities, selects Unix Makefiles explicitly, pins CMake’s C compiler to the same cc used for both archives, and fixes the configuration’s C/linker flags. Relevant inherited flag/toolchain variables are cleared only inside the driver process. These preconditions keep a policy change from becoming an accidental compiler comparison.

The CMake 3.25 command-line manual documents -S and -B for explicit source/build trees, -D for cache inputs, and cmake --build <dir> for invoking the generated buildsystem. A build tree’s CMakeCache.txt stores persistent configuration information. Record the invocation as well as the cache: they answer different questions about requested inputs and retained state.

The two libraries are built with ordinary cc -c and ar rcs, with every shell pathname quoted. Their source files are intentionally complete and tiny:

/* A/probe.c */

int probe(void) {

    return 11;

}

/* B/probe.c */

int probe(void) {

    return 22;

}

The program calling the selected archive is equally small:

/* src/main.c */

#include <stdio.h>

int probe(void);

int main(void) {

    printf("%d\n", probe());

    return 0;

}

The driver later executes these exact build commands:

cc -c "$A/probe.c" -o "$A/probe.o"

ar rcs "$A/libprobe.a" "$A/probe.o"

cc -c "$B/probe.c" -o "$B/probe.o"

ar rcs "$B/libprobe.a" "$B/probe.o"

The isolation principle is similar to the evidence discipline used when reviewing a GitHub Actions runner lifecycle: record the execution context instead of assuming a successful current process implies future or replacement state is equivalent. The technical subject here is different, however. We are not managing runners; we are qualifying one configure-time dependency lookup.

The root is disposable, but the published driver intentionally does not auto-delete it. A failed configure is evidence. Review it first; clean up only after the decision and after any records required by CI have been retained.

Instrument find_library and its validator

The following complete CMakeLists.txt removes only its owned validator.log before each configure and overwrites its own path/context records. This prevents C1’s callback or canonical-path record from masquerading as evidence for C2. It does not remove arbitrary cache entries or compiled products. Required search and approval inputs must be absolute paths, and SEARCH_ROOT must name an existing directory.

cmake_minimum_required(VERSION 3.25)

# Only this owned callback log is removed; other records are overwritten.

set(VALIDATOR_LOG "${CMAKE_BINARY_DIR}/validator.log")

file(REMOVE "${VALIDATOR_LOG}")

foreach(record selected approved selected-canonical approved-canonical context)

  file(WRITE "${CMAKE_BINARY_DIR}/${record}.txt" "")

endforeach()

project(cmake_validator_probe C)

foreach(input SEARCH_ROOT APPROVED_LIBRARY)

  if(NOT DEFINED ${input} OR NOT IS_ABSOLUTE "${${input}}")

    message(FATAL_ERROR "${input} must be an absolute path")

  endif()

endforeach()

if(NOT IS_DIRECTORY "${SEARCH_ROOT}")

  message(FATAL_ERROR "SEARCH_ROOT must be an existing directory")

endif()

option(USE_NO_CACHE "Store a new result as a normal variable" OFF)

option(RESET_LOOKUP "Clear this owned lookup before searching" OFF)

option(POST_LOOKUP_GATE "Check final selection against current policy" OFF)

file(WRITE "${CMAKE_BINARY_DIR}/context.txt"

  "cmake=${CMAKE_VERSION}\ncompiler=${CMAKE_C_COMPILER}\n"

  "compiler_id=${CMAKE_C_COMPILER_ID}\n"

  "compiler_version=${CMAKE_C_COMPILER_VERSION}\n"

  "generator=${CMAKE_GENERATOR}\nsource=${CMAKE_SOURCE_DIR}\n"

  "c_flags=${CMAKE_C_FLAGS}\nbuild_type=${CMAKE_BUILD_TYPE}\n"

  "exe_linker_flags=${CMAKE_EXE_LINKER_FLAGS}\n")

function(approved result candidate)

  file(APPEND "${VALIDATOR_LOG}" "${candidate}\n")

  file(REAL_PATH "${candidate}" candidate_real)

  file(REAL_PATH "${APPROVED_LIBRARY}" approved_real)

  if(NOT "${candidate_real}" STREQUAL "${approved_real}")

    set(${result} FALSE PARENT_SCOPE)

  endif()

endfunction()

if(RESET_LOOKUP)

  unset(PROBE_LIBRARY)

  unset(PROBE_LIBRARY CACHE)

endif()

set(lookup_options)

if(USE_NO_CACHE)

  list(APPEND lookup_options NO_CACHE)

endif()

find_library(PROBE_LIBRARY NAMES probe

  PATHS "${SEARCH_ROOT}" NO_DEFAULT_PATH

  VALIDATOR approved ${lookup_options})

if(NOT PROBE_LIBRARY OR PROBE_LIBRARY MATCHES "-NOTFOUND$")

  message(FATAL_ERROR "PROBE_LIBRARY_NOT_FOUND")

endif()

if(NOT IS_ABSOLUTE "${PROBE_LIBRARY}")

  message(FATAL_ERROR "PROBE_LIBRARY must be an absolute path")

endif()

file(WRITE "${CMAKE_BINARY_DIR}/selected.txt" "${PROBE_LIBRARY}\n")

file(WRITE "${CMAKE_BINARY_DIR}/approved.txt" "${APPROVED_LIBRARY}\n")

if(EXISTS "${PROBE_LIBRARY}" AND NOT IS_DIRECTORY "${PROBE_LIBRARY}")

  file(REAL_PATH "${PROBE_LIBRARY}" selected_real)

  file(WRITE "${CMAKE_BINARY_DIR}/selected-canonical.txt"

    "${selected_real}\n")

endif()

if(EXISTS "${APPROVED_LIBRARY}" AND NOT IS_DIRECTORY "${APPROVED_LIBRARY}")

  file(REAL_PATH "${APPROVED_LIBRARY}" approved_real)

  file(WRITE "${CMAKE_BINARY_DIR}/approved-canonical.txt"

    "${approved_real}\n")

endif()

message(STATUS "SELECTED_LIBRARY=${PROBE_LIBRARY}")

message(STATUS "CURRENT_APPROVED_LIBRARY=${APPROVED_LIBRARY}")

if(POST_LOOKUP_GATE)

  if(NOT EXISTS "${PROBE_LIBRARY}" OR IS_DIRECTORY "${PROBE_LIBRARY}")

    message(FATAL_ERROR "POST_LOOKUP_SELECTED_MISSING_OR_DIRECTORY")

  endif()

  if(NOT EXISTS "${APPROVED_LIBRARY}" OR IS_DIRECTORY "${APPROVED_LIBRARY}")

    message(FATAL_ERROR "POST_LOOKUP_APPROVED_MISSING_OR_DIRECTORY")

  endif()

  file(REAL_PATH "${PROBE_LIBRARY}" selected_real)

  file(REAL_PATH "${APPROVED_LIBRARY}" approved_real)

  if(NOT "${selected_real}" STREQUAL "${approved_real}")

    message(FATAL_ERROR "POST_LOOKUP_MISMATCH:\n"

      "selected=${selected_real}\napproved=${approved_real}")

  endif()

endif()

add_executable(probe_app main.c)

target_link_libraries(probe_app PRIVATE "${PROBE_LIBRARY}")

The validator signature is function(approved result candidate). The documented VALIDATOR interface passes a result-variable name and an absolute candidate path to a function, not a macro. Setting that result variable false in the calling scope rejects the candidate. A reused cached result bypasses this callback. The separate final-path check therefore remains necessary when approval must apply on every configure.

The NO_DEFAULT_PATH flag is part of the fixture boundary. It makes SEARCH_ROOT the relevant lookup location instead of allowing unrelated system paths to muddy the experiment. The explicit “not found” check then turns absence of a candidate into a configure failure rather than permitting later logic to reason over PROBE_LIBRARY-NOTFOUND.

The three switches are experimental controls. POST_LOOKUP_GATE is disabled only in C1–C3 so the misleading success remains visible. In an acceptance configuration, keep the final-path check active rather than expose its disabling switch as a routine escape hatch. RESET_LOOKUP owns only PROBE_LIBRARY; it does not promise to invalidate every piece of dependency-related state in a larger project.

Keep the approval oracle outside the cache

APPROVED_LIBRARY is not derived from PROBE_LIBRARY, the cache, selected.txt, or runtime output. The driver defines A_LIB="$A/libprobe.a" and B_LIB="$B/libprobe.a" from the controlled fixture layout, then chooses one according to the case manifest. That independence is critical.

There are therefore three distinct values to preserve for review: the declared approved path, the path returned by find_library(), and the archive behavior that reached the executable. C1 happens to align all three on A. C2 and C3 deliberately do not. C4 turns that disagreement into a configure failure. C5 and C6 realign them on B.

This is why the post-lookup check is stronger than “I saw a validator function in CMakeLists.txt.” Code presence does not establish execution. A reviewer needs run-specific evidence tied to the final selected value. A discovery callback can qualify a new candidate; an unconditional final-path gate also checks results inherited from prior state.

Establish the approved A baseline

C1 creates the baseline in reuse/ under policy A. SEARCH_ROOT points to A/, APPROVED_LIBRARY names A/libprobe.a, USE_NO_CACHE is off, reset is off, and the post-lookup gate is off. Because PROBE_LIBRARY is not already set in this new build tree, find_library() searches. It finds A, calls approved, and stores A in the cache.

The callback log for the observed run contains exactly the A archive path. The configure succeeds. The generated target links the selected absolute path. Building and running probe_app prints 11. At this point the cache and the policy agree, so the cached value is not a problem.

The distinction between process success and state meaning is worth making explicit. C1 is acceptable because the evidence lines up: policy A says A, the search finds A, the callback runs on A, the cache records A, and the executable proves that A’s marker reached runtime. The same pattern of checking effect rather than merely trusting a successful command appears in Make recipe shell-context validation, but that page addresses shell working directories, not CMake cache lifetime.

For this fixture, C1 establishes a known-good historical state. It does not grant A permanent approval. Approval is a property of the current policy, not a lifetime attribute magically attached to the cache entry. When the policy changes to B in C2, yesterday’s valid result becomes today’s stale result unless the current run independently re-approves it.

Read the cache line and callback candidate as independent observations. An entry ending in A/libprobe.a is not enough if the callback log belongs to an earlier invocation. Conversely, a callback mentioning A would not qualify a later selection of B. C1 is a discovery baseline under A; the complete release pattern demonstrated later also keeps the final-path gate active.

Capture evidence for each configure separately

The driver creates evidence/C1 through evidence/C6. Each case records its independent approval manifest, incoming cache, exact configure arguments, stdout, stderr, return code, selected/approved paths, canonical paths, effective compiler/generator context, outgoing cache, and callback-file presence. Permitted builds add verbose build logs, their return codes, and the generated link command. Permitted executions add separate runtime stdout, stderr, return code, and marker records.

Evidence is copied immediately after configure, before an unexpected outcome terminates the harness. An absent callback file gets an explicit presence record; an unexpectedly present but empty file is not silently accepted as “no callback.” The driver requires current path and context records before interpreting the case. C4 records build and runtime as not-run. An executable left by C3 is not submitted as evidence for C4.

Change the policy while preserving the build directory

C2 is the misleading case. It reuses the same reuse/ directory, changes the policy inputs to B, and changes SEARCH_ROOT to B, but leaves PROBE_LIBRARY cached from C1. The source files, generator, compiler environment, and archive contents remain unchanged.

The find_library() cache contract explains C2: a previously successful stored value suppresses a new search, and reusing that cached result bypasses the validator. Consequently, changing PATHS "${SEARCH_ROOT}" alone does not force discovery in B. The existing lookup variable still names A.

In the observed C2 configure, stdout reports selected A and approved B. Configure exits successfully because the unconditional post-lookup gate is not enabled yet. The owned validator log is absent for this completed lookup, the cache still records A, building succeeds, and probe_app prints 11. The harness checks these records together rather than treating missing logs from an incomplete configure as approval evidence.

Every one of those facts is internally consistent, yet policy B is violated. CMake did what its documented cache contract permits. The error would be in a release process that interprets “configure returned zero” or “PROBE_LIBRARY is a path” as “the selected dependency passed policy B.” Neither statement follows.

This is the sharpest reason to record callback evidence. When a policy change is expected to trigger candidate approval, callback absence is not harmless noise. It tells the reviewer that this configure did not establish the approval fact through the validator path. A cache hit may still be acceptable, but only if another current check approves the selected file.

Test NO_CACHE against an already-set result

C3 retains the successful A cache entry and policy B, then adds NO_CACHE. Its documented storage behavior is narrower than its name can suggest: a newly searched result goes into a normal variable rather than a cache entry. It does not mean “ignore prior state” or “rerun validators.” Here the existing successful path prevents discovery, so there is no new result to store. NO_CACHE was added in CMake 3.21, not with VALIDATOR in 3.25.

So C3 returns A again. No validator call occurs. The existing cache entry remains A because there was no new search to replace anything. Configure succeeds, build succeeds, and the executable still prints 11. This is not a CMake bug and it is not contradictory behavior. Storage policy for a new result is distinct from invalidation policy for an old one.

Operationally, that distinction matters in CI review. A change that adds NO_CACHE may look like it “fixes caching,” but if the same variable is already populated in a reused configure scope, it may change nothing about the current selection. The right review question is: what state exists before this call, and what exact condition causes a search?

The “already-set” shorthand in this discussion means the successful path value used by the fixture. It should not be generalized to a NOTFOUND result: unsuccessful discovery can be retried. Nor does adding NO_CACHE retrospectively remove an existing cache entry. Read C3’s before/after cache snapshots together with its callback-presence record to establish what this invocation actually did.

Separate normal variables from cache entries

CMake has two relevant namespaces here: scoped normal variables and cache entries persisted in the build tree. The unset() command distinguishes unset(VAR), which removes the normal binding in the current scope, from unset(VAR CACHE), which removes the cache entry. The variable lookup rules search normal values before falling back to the cache. Removing a normal value can therefore expose a previously hidden cache value rather than leave the reference empty.

The CMake 3.25 set reference also distinguishes creating a normal binding from writing a cache entry. Keep that distinction visible when reviewing code that initializes a lookup before find_library() executes.

Save the following as shadowing.cmake and execute cmake -P shadowing.cmake. This separate comparison was reproduced with the same CMake 3.31.6 runtime:

cmake_minimum_required(VERSION 3.25)

# shadowing.cmake

# Case 1: clearing a normal variable can expose an existing cache entry.

set(DEMO "cache-A" CACHE STRING "fixture cache" FORCE)

set(DEMO "normal-A")

message(STATUS "before_normal_unset=${DEMO}")

unset(DEMO)

message(STATUS "after_normal_unset=${DEMO}")

# Case 2: clearing only the cache can leave an existing normal variable.

unset(DEMO CACHE)

set(DEMO "cache-B" CACHE STRING "fixture cache" FORCE)

set(DEMO "normal-B")

message(STATUS "before_cache_unset=${DEMO}")

unset(DEMO CACHE)

message(STATUS "after_cache_unset=${DEMO}")

Observed output, CMake 3.31.6:

-- before_normal_unset=normal-A

-- after_normal_unset=cache-A

-- before_cache_unset=normal-B

-- after_cache_unset=normal-B

That is why C5 clears both forms for this owned lookup. A blanket claim such as “just unset it” is underspecified unless the scope and storage form are known.

Perform invalidation where this lookup is owned. Clearing a cache entry in a helper does not prevent surrounding project code from defining a normal value again before find_library() runs. The example illustrates the two storage forms; it is not a universal reset for every nested scope. Preserve the incoming value before changing it, then inspect the value actually visible at the lookup.

Add a post-lookup gate that runs on cache hits

C4 retains stale A and policy B, then enables the post-lookup gate. It runs after find_library() whether selection came from a search or prior state. It requires both selected and approved paths to exist and rejects directories, resolves both with file(REAL_PATH), and compares their canonical names. The absolute-path precondition prevents this local policy from depending on an unexplained relative base.

The observed configure exits nonzero with POST_LOOKUP_MISMATCH because selected A differs from approved B. The callback file is absent: discovery still did not occur. This combination is exactly the gap the gate closes. No callback approved a new candidate, but a current independent check rejected the final selected path.

The failed configure is not followed by a build or runtime execution. reuse/probe_app may still exist from C3, but its existence is historical residue, not evidence for C4. Running it after a failed configure would answer the wrong question: “what does the old binary do?” The release question is whether the current configuration passed. It did not.

A post-lookup gate is therefore not a replacement for the validator. The validator is still valuable during discovery because it can reject candidates while CMake searches. The unconditional gate serves a different role: it applies the current policy to the final selected value even on cache hits. In a narrow exact-path policy like this one, the two controls compose cleanly.

Fail the release path rather than printing a warning

The gate uses message(FATAL_ERROR), not a warning. The driver invokes configure inside an explicit conditional, captures its return code, and saves the evidence before deciding whether the outcome matches the manifest. C4 must fail and contain POST_LOOKUP_MISMATCH. Its build/runtime records are not-run. Any unexpected result calls fail(), which exits nonzero.

An expected configuration failure is different from a harness failure. C4 validates rejection only when the intended path mismatch caused it. An unexpected success in C4, unexpected configure failure in another case, wrong selected path, wrong cache value, missing required evidence, build failure, or wrong runtime marker terminates the harness. The exit trap explicitly preserves the process status while printing the owned root. A later successful summary command cannot turn that explicit failure into success.

Invalidate only the owned lookup and select B

C5 repairs the lookup state without deleting unrelated cache content. Before calling find_library(), the fixture executes both unset(PROBE_LIBRARY) and unset(PROBE_LIBRARY CACHE) in the owned lookup scope. This is intentionally narrow: PROBE_LIBRARY belongs to this fixture, and both possible storage forms are cleared. The unset() semantics are documented separately for normal and cache variables, which is why both operations are explicit.

Policy B and the post-lookup gate remain active. With the stale state removed, find_library() now searches B, discovers B/libprobe.a, invokes the validator, and records B. The post-lookup gate independently canonicalizes the final selected path and the manifest’s approved path and accepts their equality. Configure succeeds. Build succeeds. The executable prints 22.

This is the preferred repair when the lookup variable is clearly owned and the necessary invalidation boundary is known. It changes only the state whose validity rule changed. In a large build tree, indiscriminate cache deletion can destroy unrelated configuration choices, complicate diagnosis, and create new differences that have nothing to do with the dependency under review. CMake’s command-line manual even warns that broad cache-entry removal with -U should be used with care because it can make a cache non-working.

Narrow invalidation still has a cost: it forces this lookup to be performed again. That cost is appropriate when policy has changed and a new discovery/approval step is required. It should be an owned, deliberate action, not a habit of purging arbitrary entries until a configure “goes green.”

C5 also demonstrates the nuanced conclusion: caching is not the enemy. B is now cached. A later run may reuse B while satisfying this narrow path policy if the unconditional gate still approves the current selected name and the required build/runtime evidence agrees. That does not establish authenticity or protect against later file changes. C2 and C3 failed because reuse was unqualified under a changed rule.

Compare with a genuinely separate build tree

C6 configures fresh/, a build directory that has never inherited the PROBE_LIBRARY state from reuse/. Policy B and the post-lookup gate remain active. find_library() searches B, the validator runs, B is selected, configure succeeds, build succeeds, and the executable prints 22.

The agreement between C5 and C6 is useful evidence. Targeted invalidation in a reused tree and discovery in a separate tree converge on the same selected library and functional marker under policy B. This supports the narrow conclusion that the repaired reused lookup and the new lookup agree for this fixture.

It does not prove that reuse/ and fresh/ are identical in every respect. The comparison concerns this lookup and executable only. Other cache entries, compiler-detection results, generated files, and build artifacts may differ. Nor does a fresh build tree prove authenticity, ABI completeness, or immutability after configure.

Do not mistake a fresh cache for complete artifact provenance

The fixture uses a separate fresh/ directory. The documented --fresh option instead removes CMakeCache.txt and its associated CMakeFiles/ directory before recreating configuration state. That is not a promise to delete every generated or compiled artifact in the build tree, nor does it replace dependency verification. Distinguish a fresh configuration from a genuinely separate build directory.

For release decisions, “fresh” therefore needs an owned definition. Here it means a distinct directory with no inherited CMake state from the reused tree. In a production system, a fresh configure might still consume external libraries, generated inputs, environment state, or files that can mutate independently. Those concerns require their own controls and are outside this article’s scope.

Read path, callback, cache, and executable evidence together

The complete printed driver was executed with CMake 3.31.6, cc (Debian 14.2.0-19) 14.2.0, and Unix Makefiles. The recorded root was /tmp/cmake-validator-probe.1pIKQw. The table summarizes that observed execution. Selected/cache paths are abbreviated relative to this root; the retained evidence contains complete absolute paths.

Case

Policy / control

Callback

Configure

Selected path

Cache path

Marker

C1

A

true

true

A/libprobe.a

A/libprobe.a

11

C2

B

false

true

A/libprobe.a

A/libprobe.a

11

C3

B; NO_CACHE

false

true

A/libprobe.a

A/libprobe.a

11

C4

B; gate

false

false

A/libprobe.a

A/libprobe.a

not-run

C5

B; reset; gate

true

true

B/libprobe.a

B/libprobe.a

22

C6

B; fresh/; gate

true

true

B/libprobe.a

B/libprobe.a

22

The literal row oracle is therefore satisfied: validator_called = [true,false,false,false,true,true]; configure_ok = [true,true,true,false,true,true]; runtime markers = [11,11,11,not-run,22,22].

Each column answers a different question. validator_called asks whether discovery-time candidate approval happened in this configure. configure_ok asks whether the configuration process as a whole passed its active gates. selected path records what CMake handed to the target. cache path records persistent lookup state. runtime marker identifies which sentinel implementation actually reached the executable for successful cases.

No single column is a substitute for the others. C2 is the proof: configure is green and the library path exists, but callback evidence is absent and the runtime marker contradicts policy B. C4 shows the corrective control: even without a callback, the current gate can inspect the final selection and reject it. C5 shows repair. C6 provides an independent build-tree comparison.

The harness’s successful completion is not a release verdict for six artifacts. It deliberately expects C2 and C3 to violate policy B and expects C4 to reject the configuration. Matching those rows validates the experiment. Only the decision contract determines which later build can be accepted. Mixing those two meanings of “pass” would recreate the exact approval error the fixture is designed to expose.

The callback column also has to be interpreted in context. false does not automatically mean failure: under an intentionally reused, independently approved cache value, the validator may correctly be absent because no discovery happened. What matters is whether some current rule establishes approval of the final selected file. Conversely, true is not automatic acceptance either. A validator could run on candidates that are later rejected, a later statement could replace a variable, or a post-lookup existence check could still fail. That is why the accepted record is tied to the final selected path and the build that consumed it, not merely to the fact that a callback executed at some point during configure.

The cache column is likewise evidence of persistence, not evidence of correctness. In C1 it records a value that is both historically and currently approved under policy A. In C2 through C4 the same bytes are historically understandable but currently wrong under policy B. After C5 the cache records B, which is acceptable because the current gate approves B. The meaning of the cache entry changes with policy even though the storage mechanism does not.

Observed C4 error excerpt, CMake 3.31.6:

CMake Error at CMakeLists.txt:86 (message):

  POST_LOOKUP_MISMATCH:

  selected=/tmp/cmake-validator-probe.1pIKQw/A/libprobe.a

  approved=/tmp/cmake-validator-probe.1pIKQw/B/libprobe.a

That failure is valuable evidence. Preserve it before repair.

Repair the CI contract around configuration reuse

A reliable CI contract should state when lookup results may be reused and what must be re-evaluated on every configure. For this exact-path policy, the simplest durable rule is: the dependency owner supplies an independently approved absolute path; CMake may reuse its stored lookup result; an unconditional post-lookup gate compares the selected and approved canonical file paths on every configure; and policy-changing work that requires rediscovery explicitly invalidates the owned lookup or uses a separate build tree.

This avoids two opposite mistakes. The first is assuming every cache hit is bad, which throws away useful incremental configuration without cause. The second is assuming every existing cache value remains qualified forever, which lets an obsolete policy decision masquerade as current approval.

CI should retain the configure invocation, configure stdout/stderr, selected path, relevant cache entry, callback log, build result, and runtime sentinel for the accepted run. Unknown or missing records are not success. If the source path, flags, selected library identity, or actual configure/build sequence cannot be reconstructed, the release reviewer should HOLD rather than infer a favorable story.

A useful review invariant is that every accepted dependency-selection record can answer four questions without consulting mutable workspace state: what policy was requested, what path was selected, what check approved that path in this configure, and what built executable demonstrated the expected sentinel. The fixture stores these as separate files because separation prevents accidental inference. In a production pipeline they may be structured fields in one provenance record, but the logical independence should remain.

Policy changes also deserve an explicit trigger. Changing an approved path, changing a validator rule, or changing the permitted search roots should open a configuration-qualification step rather than relying on whatever a long-lived build directory happens to contain. That does not require deleting the whole tree. It requires the owner to decide whether the current post-lookup gate is sufficient or whether the relevant discovery state must be invalidated and rerun.

The source-input side of a release has its own evidence problems; for that adjacent concern, see complete Git build-input checks. Do not merge the two controls mentally. A perfectly clean Git tree can still reuse a stale CMake library result, and a correctly revalidated CMake lookup does not prove the source tree is clean.

The full self-contained driver follows. Save it as cmake-validator-probe.sh and run bash cmake-validator-probe.sh, not sh. It uses Bash arrays and GNU-style Linux utilities. It creates the source files itself, so the earlier listings need not be assembled manually. It checks both the literal outcome oracle and independently specified path/cache expectations. Its zero exit status means the experiment matched the manifest, not that every case met policy B.

#!/usr/bin/env bash

set -euo pipefail

export LC_ALL=C

# These changes affect only the driver's process environment.

unset CC CFLAGS CPPFLAGS LDFLAGS CMAKE_TOOLCHAIN_FILE

unset CMAKE_C_COMPILER_LAUNCHER CMAKE_C_LINKER_LAUNCHER MAKEFLAGS

for tool in cmake cc ar make mktemp cp sed cmp grep; do

  command -v "$tool" >/dev/null || {

    printf 'Missing prerequisite: %s\n' "$tool" >&2

    exit 10

  }

done

CC_BIN="$(command -v cc)"

ROOT="$(mktemp -d /tmp/cmake-validator-probe.XXXXXX)"

ROOT="$(cd -- "$ROOT" && pwd -P)"

finish() {

  local rc=$?

  trap - EXIT

  printf 'fixture_root=%s\n' "$ROOT" >&2

  exit "$rc"

}

trap finish EXIT

A="$ROOT/A"

B="$ROOT/B"

SRC="$ROOT/src"

REUSE="$ROOT/reuse"

FRESH="$ROOT/fresh"

EVIDENCE="$ROOT/evidence"

mkdir -p "$A" "$B" "$SRC" "$REUSE" "$FRESH" "$EVIDENCE"

fail() {

  printf 'HARNESS ERROR: %s\n' "$*" >&2

  exit 20

}

run_command() {

  local stem="$1" rc

  shift

  printf '%q ' "$@" > "$stem.args"

  printf '\n' >> "$stem.args"

  if "$@" > "$stem.stdout" 2> "$stem.stderr"; then

    rc=0

  else

    rc=$?

  fi

  printf '%s\n' "$rc" > "$stem.rc"

  return "$rc"

}

snapshot() {

  local source="$1" target="$2"

  if [[ -f "$source" ]]; then

    cp -- "$source" "$target"

    printf 'present\n' > "$target.presence"

  elif [[ -e "$source" || -L "$source" ]]; then

    fail "Not a readable ordinary file: $source"

  else

    : > "$target"

    printf 'absent\n' > "$target.presence"

  fi

}

run_command "$EVIDENCE/cmake-version" cmake --version || fail 'cmake version'

run_command "$EVIDENCE/compiler-version" cc --version || fail 'cc version'

run_command "$EVIDENCE/ar-version" ar --version || fail 'ar version'

run_command "$EVIDENCE/make-version" make --version || fail 'make version'

printf '%s\n' "$BASH_VERSION" > "$EVIDENCE/bash-version.txt"

printf '%s\n' 'Unix Makefiles' > "$EVIDENCE/generator.txt"

for tool in cmake cc ar make; do

  printf '%s=%s\n' "$tool" "$(command -v "$tool")"

done > "$EVIDENCE/tool-paths.txt"

cat > "$A/probe.c" <<'SRC_A'

int probe(void) {

    return 11;

}

SRC_A

cat > "$B/probe.c" <<'SRC_B'

int probe(void) {

    return 22;

}

SRC_B

cat > "$SRC/main.c" <<'MAIN'

#include <stdio.h>

int probe(void);

int main(void) {

    printf("%d\n", probe());

    return 0;

}

MAIN

cat > "$SRC/CMakeLists.txt" <<'CMAKE'

cmake_minimum_required(VERSION 3.25)

# Only this owned callback log is removed; other records are overwritten.

set(VALIDATOR_LOG "${CMAKE_BINARY_DIR}/validator.log")

file(REMOVE "${VALIDATOR_LOG}")

foreach(record selected approved selected-canonical approved-canonical context)

  file(WRITE "${CMAKE_BINARY_DIR}/${record}.txt" "")

endforeach()

project(cmake_validator_probe C)

foreach(input SEARCH_ROOT APPROVED_LIBRARY)

  if(NOT DEFINED ${input} OR NOT IS_ABSOLUTE "${${input}}")

    message(FATAL_ERROR "${input} must be an absolute path")

  endif()

endforeach()

if(NOT IS_DIRECTORY "${SEARCH_ROOT}")

  message(FATAL_ERROR "SEARCH_ROOT must be an existing directory")

endif()

option(USE_NO_CACHE "Store a new result as a normal variable" OFF)

option(RESET_LOOKUP "Clear this owned lookup before searching" OFF)

option(POST_LOOKUP_GATE "Check final selection against current policy" OFF)

file(WRITE "${CMAKE_BINARY_DIR}/context.txt"

  "cmake=${CMAKE_VERSION}\ncompiler=${CMAKE_C_COMPILER}\n"

  "compiler_id=${CMAKE_C_COMPILER_ID}\n"

  "compiler_version=${CMAKE_C_COMPILER_VERSION}\n"

  "generator=${CMAKE_GENERATOR}\nsource=${CMAKE_SOURCE_DIR}\n"

  "c_flags=${CMAKE_C_FLAGS}\nbuild_type=${CMAKE_BUILD_TYPE}\n"

  "exe_linker_flags=${CMAKE_EXE_LINKER_FLAGS}\n")

function(approved result candidate)

  file(APPEND "${VALIDATOR_LOG}" "${candidate}\n")

  file(REAL_PATH "${candidate}" candidate_real)

  file(REAL_PATH "${APPROVED_LIBRARY}" approved_real)

  if(NOT "${candidate_real}" STREQUAL "${approved_real}")

    set(${result} FALSE PARENT_SCOPE)

  endif()

endfunction()

if(RESET_LOOKUP)

  unset(PROBE_LIBRARY)

  unset(PROBE_LIBRARY CACHE)

endif()

set(lookup_options)

if(USE_NO_CACHE)

  list(APPEND lookup_options NO_CACHE)

endif()

find_library(PROBE_LIBRARY NAMES probe

  PATHS "${SEARCH_ROOT}" NO_DEFAULT_PATH

  VALIDATOR approved ${lookup_options})

if(NOT PROBE_LIBRARY OR PROBE_LIBRARY MATCHES "-NOTFOUND$")

  message(FATAL_ERROR "PROBE_LIBRARY_NOT_FOUND")

endif()

if(NOT IS_ABSOLUTE "${PROBE_LIBRARY}")

  message(FATAL_ERROR "PROBE_LIBRARY must be an absolute path")

endif()

file(WRITE "${CMAKE_BINARY_DIR}/selected.txt" "${PROBE_LIBRARY}\n")

file(WRITE "${CMAKE_BINARY_DIR}/approved.txt" "${APPROVED_LIBRARY}\n")

if(EXISTS "${PROBE_LIBRARY}" AND NOT IS_DIRECTORY "${PROBE_LIBRARY}")

  file(REAL_PATH "${PROBE_LIBRARY}" selected_real)

  file(WRITE "${CMAKE_BINARY_DIR}/selected-canonical.txt"

    "${selected_real}\n")

endif()

if(EXISTS "${APPROVED_LIBRARY}" AND NOT IS_DIRECTORY "${APPROVED_LIBRARY}")

  file(REAL_PATH "${APPROVED_LIBRARY}" approved_real)

  file(WRITE "${CMAKE_BINARY_DIR}/approved-canonical.txt"

    "${approved_real}\n")

endif()

message(STATUS "SELECTED_LIBRARY=${PROBE_LIBRARY}")

message(STATUS "CURRENT_APPROVED_LIBRARY=${APPROVED_LIBRARY}")

if(POST_LOOKUP_GATE)

  if(NOT EXISTS "${PROBE_LIBRARY}" OR IS_DIRECTORY "${PROBE_LIBRARY}")

    message(FATAL_ERROR "POST_LOOKUP_SELECTED_MISSING_OR_DIRECTORY")

  endif()

  if(NOT EXISTS "${APPROVED_LIBRARY}" OR IS_DIRECTORY "${APPROVED_LIBRARY}")

    message(FATAL_ERROR "POST_LOOKUP_APPROVED_MISSING_OR_DIRECTORY")

  endif()

  file(REAL_PATH "${PROBE_LIBRARY}" selected_real)

  file(REAL_PATH "${APPROVED_LIBRARY}" approved_real)

  if(NOT "${selected_real}" STREQUAL "${approved_real}")

    message(FATAL_ERROR "POST_LOOKUP_MISMATCH:\n"

      "selected=${selected_real}\napproved=${approved_real}")

  endif()

endif()

add_executable(probe_app main.c)

target_link_libraries(probe_app PRIVATE "${PROBE_LIBRARY}")

CMAKE

run_command "$EVIDENCE/A-compile" cc -c "$A/probe.c" -o "$A/probe.o" \

  || fail 'A compilation'

run_command "$EVIDENCE/A-archive" ar rcs "$A/libprobe.a" "$A/probe.o" \

  || fail 'A archive'

run_command "$EVIDENCE/B-compile" cc -c "$B/probe.c" -o "$B/probe.o" \

  || fail 'B compilation'

run_command "$EVIDENCE/B-archive" ar rcs "$B/libprobe.a" "$B/probe.o" \

  || fail 'B archive'

case_run() {

  local id="$1" build_dir="$2" search_root="$3" approved_library="$4"

  local no_cache="$5" reset="$6" gate="$7" expect_ok="$8"

  local expect_validator="$9" expect_marker="${10}"

  local expect_selected="${11}" expect_before="${12}"

  local out="$EVIDENCE/$id" rc actual_ok called record marker

  mkdir "$out"

  printf 'approved=%s\nexpected_selected=%s\nexpected_marker=%s\n' \

    "$approved_library" "$expect_selected" "$expect_marker" \

    > "$out/manifest.txt"

  snapshot "$build_dir/CMakeCache.txt" "$out/cache-before.txt"

  sed -n '/^PROBE_LIBRARY:/p' "$out/cache-before.txt" \

    > "$out/cache-entry-before.txt"

  [[ "$(<"$out/cache-entry-before.txt")" == "$expect_before" ]] \

    || fail "$id: unexpected incoming cache state"

  local args=(

    cmake -S "$SRC" -B "$build_dir" -G "Unix Makefiles"

    "-DCMAKE_C_COMPILER:FILEPATH=$CC_BIN"

    '-DCMAKE_BUILD_TYPE:STRING=' '-DCMAKE_C_FLAGS:STRING='

    '-DCMAKE_EXE_LINKER_FLAGS:STRING='

    "-DSEARCH_ROOT:PATH=$search_root"

    "-DAPPROVED_LIBRARY:FILEPATH=$approved_library"

    "-DUSE_NO_CACHE:BOOL=$no_cache"

    "-DRESET_LOOKUP:BOOL=$reset"

    "-DPOST_LOOKUP_GATE:BOOL=$gate"

  )

  if run_command "$out/configure" "${args[@]}"; then

    rc=0

  else

    rc=$?

  fi

  # Preserve evidence before interpreting even an unexpected failure.

  for record in selected approved selected-canonical approved-canonical context; do

    snapshot "$build_dir/$record.txt" "$out/$record.txt"

  done

  snapshot "$build_dir/CMakeCache.txt" "$out/cache-after.txt"

  snapshot "$build_dir/validator.log" "$out/validator.log"

  sed -n '/^PROBE_LIBRARY:/p' "$out/cache-after.txt" > "$out/cache-entry.txt"

  actual_ok=false

  if (( rc == 0 )); then actual_ok=true; fi

  [[ "$actual_ok" == "$expect_ok" ]] || fail "$id: configure outcome"

  for record in selected approved selected-canonical approved-canonical context; do

    [[ -s "$out/$record.txt" ]] || fail "$id: missing current $record"

  done

  [[ "$(<"$out/selected.txt")" == "$expect_selected" ]] \

    || fail "$id: selected path"

  [[ "$(<"$out/selected-canonical.txt")" == "$expect_selected" ]] \

    || fail "$id: canonical selected path"

  [[ "$(<"$out/approved.txt")" == "$approved_library" ]] \

    || fail "$id: approval input"

  [[ "$(<"$out/approved-canonical.txt")" == "$approved_library" ]] \

    || fail "$id: canonical approval input"

  [[ "$(<"$out/cache-entry.txt")" == "PROBE_LIBRARY:FILEPATH=$expect_selected" ]] \

    || fail "$id: cache entry"

  called=false

  if [[ "$(<"$out/validator.log.presence")" == present ]]; then

    [[ -s "$out/validator.log" ]] || fail "$id: empty callback log"

    called=true

    [[ "$(<"$out/validator.log")" == "$expect_selected" ]] \

      || fail "$id: callback candidate"

  fi

  printf '%s\n' "$called" > "$out/validator-called.txt"

  [[ "$called" == "$expect_validator" ]] || fail "$id: callback outcome"

  if [[ "$expect_ok" == false ]]; then

    printf 'not-run\n' > "$out/build-result.txt"

    printf 'not-run\n' > "$out/runtime-marker.txt"

    grep -q 'POST_LOOKUP_MISMATCH' "$out/configure.stderr" \

      || fail "$id: wrong rejection reason"

    return 0

  fi

  run_command "$out/build" cmake --build "$build_dir" --verbose \

    || fail "$id: build failed"

  printf 'ok\n' > "$out/build-result.txt"

  snapshot "$build_dir/CMakeFiles/probe_app.dir/link.txt" "$out/link.txt"

  [[ -s "$out/link.txt" ]] || fail "$id: missing generated link command"

  for record in selected approved selected-canonical approved-canonical context; do

    cmp -s "$out/$record.txt" "$build_dir/$record.txt" \

      || fail "$id: configure record changed during build"

  done

  run_command "$out/runtime" "$build_dir/probe_app" \

    || fail "$id: executable failed"

  marker="$(<"$out/runtime.stdout")"

  printf '%s\n' "$marker" > "$out/runtime-marker.txt"

  [[ "$marker" == "$expect_marker" ]] || fail "$id: runtime marker"

}

A_LIB="$A/libprobe.a"

B_LIB="$B/libprobe.a"

CACHE_A="PROBE_LIBRARY:FILEPATH=$A_LIB"

case_run C1 "$REUSE" "$A" "$A_LIB" OFF OFF OFF true true 11 "$A_LIB" ''

case_run C2 "$REUSE" "$B" "$B_LIB" OFF OFF OFF true false 11 "$A_LIB" "$CACHE_A"

case_run C3 "$REUSE" "$B" "$B_LIB" ON OFF OFF true false 11 "$A_LIB" "$CACHE_A"

case_run C4 "$REUSE" "$B" "$B_LIB" OFF OFF ON false false not-run \

  "$A_LIB" "$CACHE_A"

case_run C5 "$REUSE" "$B" "$B_LIB" OFF ON ON true true 22 "$B_LIB" "$CACHE_A"

case_run C6 "$FRESH" "$B" "$B_LIB" OFF OFF ON true true 22 "$B_LIB" ''

expected_validator=(true false false false true true)

expected_config=(true true true false true true)

expected_marker=(11 11 11 not-run 22 22)

ids=(C1 C2 C3 C4 C5 C6)

printf 'case,validator_called,configure_ok,marker,selected,cache\n' \

  > "$EVIDENCE/summary.csv"

for i in "${!ids[@]}"; do

  id="${ids[$i]}"

  out="$EVIDENCE/$id"

  called="$(<"$out/validator-called.txt")"

  actual_ok=false

  if [[ "$(<"$out/configure.rc")" == 0 ]]; then actual_ok=true; fi

  marker="$(<"$out/runtime-marker.txt")"

  [[ "$called" == "${expected_validator[$i]}" ]] || fail "$id: final callback"

  [[ "$actual_ok" == "${expected_config[$i]}" ]] || fail "$id: final configure"

  [[ "$marker" == "${expected_marker[$i]}" ]] || fail "$id: final marker"

  printf '%s,%s,%s,%s,%s,%s\n' "$id" "$called" "$actual_ok" "$marker" \

    "$(<"$out/selected.txt")" "$(<"$out/cache-entry.txt")" \

    >> "$EVIDENCE/summary.csv"

done

cat "$EVIDENCE/summary.csv"

printf 'evidence_dir=%s\n' "$EVIDENCE"

This printed driver was executed in the recorded laboratory. It preserves the root after success and after failure, never derives APPROVED_LIBRARY from PROBE_LIBRARY, and records policy B separately from the deliberately stale A observation expected in C2 through C4. Its generated CMakeLists.txt is the same fixture shown earlier.

Observed summary, with the root shortened only for readability:

case,validator_called,configure_ok,marker,selected,cache

C1,true,true,11,.../A/libprobe.a,PROBE_LIBRARY:FILEPATH=.../A/libprobe.a

C2,false,true,11,.../A/libprobe.a,PROBE_LIBRARY:FILEPATH=.../A/libprobe.a

C3,false,true,11,.../A/libprobe.a,PROBE_LIBRARY:FILEPATH=.../A/libprobe.a

C4,false,false,not-run,.../A/libprobe.a,PROBE_LIBRARY:FILEPATH=.../A/libprobe.a

C5,true,true,22,.../B/libprobe.a,PROBE_LIBRARY:FILEPATH=.../B/libprobe.a

C6,true,true,22,.../B/libprobe.a,PROBE_LIBRARY:FILEPATH=.../B/libprobe.a

The ... prefixes above abbreviate /tmp/cmake-validator-probe.1pIKQw; they are not literal paths to execute. Full paths, original stdout/stderr, argument vectors, cache snapshots, callback presence, and process results remain in the case evidence. Review those records before removing the owned root.

Choose accept, repair, rebuild, or hold

A release gate needs decisions, not just observations. The decision table below keeps caching itself neutral and judges whether current approval evidence is sufficient.

Situation

Decision

Required action/evidence

Current post-lookup gate approves equal canonical paths; both files exist; configure, build, and functional sentinel pass

ACCEPT

Retain configure/build/run evidence for this accepted run

Cached selected path violates the current approval rule, as in C2/C3

REPAIR

Reset the owned lookup strategy or otherwise force authorized rediscovery; rerun with the post-lookup gate

Configuration history is suspect or ownership of relevant persisted state is unclear

REBUILD

Configure and build in a separate owned tree, then compare current selection and sentinel evidence

Source paths, flags, library identity, approval manifest, or actual configure/build sequence are unknown

HOLD

Obtain missing evidence; do not infer acceptance from an old executable or green wrapper status

Repaired and separate trees disagree on selection or marker

HOLD

Keep both records; investigate policy, flags, files, and the execution sequence before accepting either result

Configure passed, but current build or runtime evidence is missing

HOLD

Reproduce the missing stage after an approved configure; an older executable is not a substitute

C4 is a REPAIR trigger, not an excuse to continue building. Its nonzero configure result proves the active gate rejected A under policy B. The old executable remains useful only as historical context from C3; it is not a release candidate after C4.

C5 is the repaired accepted path. It clears only this lookup’s normal/cache state, re-searches, invokes the validator, passes the post-lookup identity rule, and produces marker 22. C6 provides a separate-tree comparison when greater confidence in configuration history is needed.

The decision can change when evidence changes. For example, a cache hit with no validator call can still be ACCEPT if the unconditional current identity rule independently approves the cached path and the functional sentinel agrees. Conversely, a callback log alone is not enough if the final selected path or runtime result is unknown.

REBUILD is deliberately not the default response to every stale lookup. A separate tree discards configuration history and may require additional work, so use it when history is suspect, ownership boundaries are unclear, or targeted repair cannot establish confidence. HOLD is different: it applies when the evidence needed to choose a repair or approve a rebuilt result is missing. It is not a diagnosis, but a refusal to convert uncertainty into release approval.

Keep failed configuration artifacts out of acceptance

The acceptance record should have a clean lineage: configure passed, build passed, runtime sentinel passed, and the selected dependency satisfied the current approval rule. A failed configure belongs in failure evidence, not in the accepted artifact chain.

Retain C4’s stdout, stderr, arguments, selected path, incoming and outgoing cache records, canonical-path records, and explicit absent-callback record before repair. Create a distinct accepted record for C5 or C6. Do not overwrite the mismatch evidence or run the old binary as though it followed an approved C4 configure.

This separation also makes review easier. A reviewer can see exactly which run established the failure, which change repaired it, and which later run earned acceptance.

Assign dependency-policy and build-state ownership

Three responsibilities need explicit owners even in a small pipeline. The dependency-policy owner defines the current approved library identity and authorizes policy changes from A to B. The configure maintainer owns the CMake lookup, invalidation boundary, validator, and post-lookup gate. The release reviewer checks that the evidence for the candidate run is complete and that no failed-run artifact was relabeled as accepted.

Those roles can be held by the same person in a small team, but the responsibilities should remain conceptually separate. The maintainer should not make the gate “pass” by redefining the approved path from the cached result. The reviewer should not approve a build solely because cmake --build returned zero. The dependency owner should not assume changing SEARCH_ROOT proves that a persisted lookup was re-executed.

The rollback strategy is similarly narrow. Roll back to a known approved manifest input and rerun the gate. Do not respond to uncertainty by deleting arbitrary cache entries across the build tree or weakening the validator. If the history is too opaque to know what else may be stale, create a separate owned build tree and classify the old tree as suspect until reconciled.

Rollback is a new policy decision, not permission to accept whichever file is convenient. A is eligible again only when the dependency owner deliberately restores approval of its exact path and a new configure/build/run sequence qualifies that choice. Renaming evidence or pointing APPROVED_LIBRARY at the observed stale result merely hides the disagreement. Keep the rejected record attached to the repair history.

These habits generalize to high-consequence software work, including the broader discipline described in software build skills in spacecraft engineering, but that adjacent article is not authority for CMake behavior. The CMake contracts cited here remain the technical source of truth for this lookup mechanism.

Ownership also determines when evidence is sufficient to move from HOLD. Missing compiler identity, unknown configure arguments, an unexplained PROBE_LIBRARY value, or absent build/run logs are not small bookkeeping gaps when the release question is specifically about current approval. Someone must reconstruct or rerun the sequence under controlled inputs.

Develop stronger delivery and CI review habits

The bounded conclusion is straightforward: find_library() returning a path tells you what CMake selected, not necessarily what the current approval rule checked. In CMake 3.25’s documented contract, a cached result can suppress both the search and VALIDATOR; NO_CACHE does not force revalidation of an already-set value. The safest narrow response is to keep the approval oracle independent, apply a post-lookup identity gate on every configure, invalidate only owned lookup state when rediscovery is required, and use a separate build tree when configuration history cannot be trusted.

The full six-case run on CMake 3.31.6 confirms the expected distinction in this laboratory: C2 and C3 configure successfully while retaining A under policy B; C4 rejects that stale selection; C5 and C6 select B and produce marker 22. That supports a release-selection claim for this fixture and environment, nothing broader.

For broader practice in version control, CI/CD, and operational delivery, readers may review the published DevOps Engineering Program curriculum. The page describes three months at 12–14 hours per week, covering Linux/scripting, Git/GitHub, CI/CD, containers, infrastructure as code, and monitoring. It also presents mentorship, practical project work, and completion certificates. Those advertised curriculum details do not establish that the program teaches CMake or this specific laboratory.