Replacing CMake Wrapper Scripts with Version-Controlled Presets
CMakePresets.json moves configure, build, and test flags out of brittle shell scripts into a version-controlled file shared by local development and CI.
29 Sept 2026, 19:28 UTC

Every CMake project eventually accumulates the same kind of drift. A new contributor runs cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug from memory. CI runs a similar command with a different generator. A release engineer keeps a shell script with environment variables that nobody else knows how to update. Six months later, the local build works, the CI build fails, and the difference is a single cache variable that exists only in a Slack message.
CMakePresets.json addresses this by moving configure, build, and test settings into a single JSON file that lives in the source tree. Instead of teaching humans to remember flags, you declare named presets that both developers and CI jobs invoke with the same short commands.
What a preset actually declares
A preset is a named bundle of arguments for a specific CMake phase. Configure presets set the generator, toolchain file, binary directory, and cache variables. Build presets select targets and parallel jobs. Test presets choose which tests run and with what environment. Because the file is version-controlled, the configuration evolves with the code that depends on it.
The file starts with a version field. Early CMake releases used schema version 1; newer releases support higher versions. Pin the version to the oldest CMake you need to support, because a newer schema will fail to parse on older binaries.
Configure presets support inheritance through inherits. A base preset can define the generator and common cache variables, while derived presets override only what changes—typically CMAKE_BUILD_TYPE or a toolchain path. This avoids copying the same generator and warning flags into every preset.
A minimal worked example
Suppose you want a local debug build and a CI release build that both use Ninja. Without presets, your CI YAML and your README each contain a long cmake invocation. With presets, both consume the same file.
{
"version": 1,
"configurePresets": [
{
"name": "base",
"hidden": true,
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}"
},
{
"name": "dev",
"inherits": "base",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug"
}
},
{
"name": "ci",
"inherits": "base",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release"
}
}
],
"buildPresets": [
{
"name": "dev",
"configurePreset": "dev"
},
{
"name": "ci",
"configurePreset": "ci"
}
],
"testPresets": [
{
"name": "dev",
"configurePreset": "dev"
}
]
}
Save this as CMakePresets.json in the project root. The base preset is marked hidden so it does not appear in cmake --list-presets, but it can still be inherited. The binaryDir uses ${sourceDir}, which expands to the directory containing the file, and ${presetName}, which expands to the name of the preset being configured. That keeps build artifacts out of the source tree without hardcoding absolute paths. These macros are only expanded in specific string fields; placing them elsewhere can cause hard errors or silent misconfiguration.
From the source directory, run these as a normal user; no elevated permissions are required:
cmake --list-presets
cmake --preset dev
cmake --build --preset dev
ctest --preset dev
Your CI job runs the identical commands. There is no wrapper script to keep in sync, and the README can point to preset names instead of reproducing a command line.
Where presets stop
Presets are declarative configuration, not a scripting language. They cannot run arbitrary shell commands, prompt for input, or evaluate conditionals beyond the macro expansion CMake provides. If your build needs to fetch a dependency, patch a file, or choose a compiler based on the host OS, that logic still belongs in a script or in CMake code itself.
Adoption is also version-gated. Configure and build presets require CMake 3.19 or newer; test presets require 3.20 or newer. If your project supports older CMake, you need a fallback path—typically a shell script or a documented manual command—for contributors who cannot upgrade. IDEs and generators may lag in support as well, so verify that your editor recognizes the file before removing the old instructions.
Verifying before you commit
JSON has no comments and no tolerance for trailing commas. A single syntax error makes CMake reject the entire file, so validate before pushing. Run cmake --version to confirm you are on at least 3.19, then execute cmake --list-presets and ctest --list-presets in a scratch project. If the presets parse, compare cmake --preset dev output against your existing manual command line: the generator, binary directory, and cache variables should match exactly.
One more caution: presets are not a sandbox. Cache variables and environment variables can still embed absolute paths that break when the project moves to a different machine or container. Treat the file as a contract, review it like code, and test it on a clean checkout before declaring victory.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.