Skip to main content

Optimizing CI Build Scripts and Enhancing Developer Experience with CMake Presets

· 7 min read
Christopher McArthur

Managing build scripts, especially in C++, can be a daunting task for development teams. CMake, with its powerful toolkit, offers a solution to this challenge. In this blog, we'll delve into the world of CMake presets and explore how they can significantly reduce CI build script complexity, leading to a more efficient and enjoyable development experience.

The Challenge of Build Scripts in C++

C++ teams often struggle with an extensive list of build systems, introducing layers of complexity. CMake, when mastered, becomes a game-changer. This blog focuses on CMake's key pillars: toolchains, presets, and build scripts. Understanding their roles and interactions is crucial for achieving a streamlined developer experience and unambiguous CI workflows.

Join us on a journey where we'll:

  • Empower developers with effortless configuration and cross-platform builds.
  • Simplify CI pipelines for efficient testing.
  • Elevate project maintainability and collaboration across diverse environments.

Applying SOLID Design Principles to Build Scripts

In this blog, we'll apply SOLID design principles to our build scripts, focusing on the distinction between tool configurations, global project settings, and target-specific options. This effective design allows for supporting multiple build environments without the need to change CMakeLists.txt for each of them.

While new tools like XMake are promising, the blog predominantly focuses on CMake, given its prevalence in over 85% of development teams. For those starting new projects under research and development efforts, exploring alternative options like Meson is recommended.

Three Layers of CMake

Understanding the distinction between toolchains, presets, and build scripts is vital. Let's break down these three layers:

  1. Toolchain:

    • Purpose: Provide information about the build system, including compiler paths, library locations, and dependencies.
    • When to use: Cross-compiling, using a specific compiler or tool-set, employing custom build systems or dependency management tools.
    • How to use: Create a toolchain file (e.g., my_toolchain.cmake) and pass it to CMake using presets.
  2. Presets:

    • Purpose: Encapsulate common configuration options, build flags, generator choices, and toolchain settings for easy reuse and sharing.
    • When to use: Managing multiple build configurations, streamlining workflows, integrating with IDEs and build systems.
    • How to use: Create a CMakePresets.json or CMakeUserPresets.json file, define presets, and use the --preset option with CMake commands.
  3. Build Scripts:

    • Purpose: Define the project's structure, targets, dependencies, and build rules using CMake's language.
    • When to use: Creating a CMake project for multi-platform builds, organizing sources, setting compile flags, and defining custom build steps.
    • How to use: Write CMakeLists.txt files in your project's root and subdirectories.

Organizing CMake Presets for Effective CI Pipelines

CMake Toolchains

With a single responsibility, toolchains focus on compiler and tool setup for specific environments. They are essential for cross-compilation and non-standard tool setups.

Example for Cross-Compiling:

build:
matrix:
toolchains:
- my_toolchain_arm.cmake
- my_toolchain_x86.cmake
run: |
cmake --preset release -DCMAKE_TOOLCHAIN_FILE="${{ matrix.toolchain }}"
cmake --build --preset release
uses: upload-artifacts@v4
with:
name: ${{ matrix.toolchain }}
paths: build/release

Example for Supporting Multiple Compilers:

build:
matrix:
compiler:
- { "c": "gcc-7", "cxx": "g++-7" }
- { "c": "gcc-11", "cxx": "g++-11" }
- { "c": "gcc-12", "cxx": "g++-12" }
run: |
cmake --preset test \
-DCMAKE_C_COMPILER="${{ matrix.compiler.c }}"
-DCMAKE_CXX_COMPILER="${{ matrix.compiler.cxx }}"
cmake --build --preset test
cmake --build --target unit_tests_run --preset test

It's entirely possible to define a preset for each configuration. This however posses significant challenges when configurations need to change over time or when downstream consumers desire different configurations. A reasonable compromise would be to make toolchain specific presets hidden: true to prevent their misuse.

Organizing Presets

Effective organization of presets enhances manageability and scalability. Follow a structured approach to organizing presets for different scenarios.

Example Preset Organization:

.
├── cmake/
│ └── CMakePresets.json
├── include
├── src
├── examples/
│ └── CMakePresets.json
├── tests/
│ └── CMakePresets.json
├── CMakePresets.json
└── CMakeUserPresets.json

It's import to keep presets open for extension, but closed for modification. You should not be be redefine or overriding values.

  • Root CMakePresets.json: Define baseline configurations for consuming or developing the project.
  • cmake/ Directory Presets: Include presets for the entire project.
  • examples/ and tests/ Directory Presets: Include presets specific to those targets.
note

This concept also extends to monorepos, each sub component could define it's own set of presets that are aggregates but the root presets. Simply substitute in core/ and driver/ or error/ and math/ to match your use case.

Including other CMakePresets.json:

{
"version": 6,
"cmakeMinimumRequired": {
"major": 3,
"minor": 25,
"patch": 0
},
"include": [
"example/CMakePresets.json",
"tests/CMakePresets.json"
],
"configurePresets": [ /* ... */ ],
"buildPresets": [ /* ... */ ]
}

Presets should not be forced to depend upon configurations that they do not use.

Defining Presets

Most of the preparation needs to happen in configure presets. It's important to also create build preset with the same name.

cmake/CMakePresets.json:

Define simple unobtrusive presets that can be inherited for more specialized. By setting a configuration preset to define the build folder, you will not need to call mkdir and you can reuse the preset name to access artifacts.

{
"name": "release",
"configuration": "Release",
"binaryDir": "${sourceDir}/build/${presetName}"
}

tests/CMakePresets.json:

Declare configurations that produce unique binaries with a narrow focus. Passing in all the global compiler flags and define the required environment variables for either configure or build.

{
"name": "asan",
"inherits": "debug",
"cacheVariables": {
"CMAKE_CXX_FLAGS": "..." // Global compiler files needed by all targets for complete results
},
"environment": {
"ASAN_OPTIONS": "..." // Configure EVN_VAR required to active more checks
}
}

Refactoring CI Pipelines

Refactor CI pipelines to leverage the simplicity and power of CMake presets.

Example CI Pipeline for Release:

Remove the hassle of having the correct working directory.

build:
run: |
cmake --preset release
cmake --build --preset release
uses: upload-artifacts@v4
with:
paths: build/release

Example CI Pipeline for ASAN (AddressSanitizer):

Setup custom "run" targets to launch tests with the correct environment variables to ensure consistency without polluting the workspace.

build:
run: |
cmake --preset asan
cmake --build --preset asan
cmake --build --target unit_tests_run --preset test

Accommodating All Developers

Flexibility is crucial. Allow developers room to work efficiently while ensuring a green production environment. Use CMakeUserPresets.json for user-specific configurations.

Example User-Specific Preset (CMakeUserPresets.json):

{
"name": "clang",
"inherits": "debug",
"generator": "Ninja",
"cacheVariables": {
"CMAKE_C_COMPILER": "clang-16",
"CMAKE_CXX_COMPILER": "clang++-16",
"CMAKE_CXX_STANDARD": 23,
"CMAKE_CXX_CLANG_TIDY": "clang-tidy-16;fix"
}
}

Example Platform Specific Preset:

{
"name": "ccache-linux",
"hidden": true,
"cacheVariables": {
"CMAKE_C_COMPILER_LAUNCHER": "/usr/bin/ccache",
"CMAKE_CXX_COMPILER_LAUNCHER": "/usr/bin/ccache"
},
"condition": {
"type": "equals",
"lhs": "${hostSystemName}",
"rhs": "Linux"
}
},
{
"name": "ccache-darwin",
"hidden": true,
"cacheVariables": {
"CMAKE_C_COMPILER_LAUNCHER": "$env{HOMEBREW_PREFIX}/opt/ccache",
"CMAKE_CXX_COMPILER_LAUNCHER": "$env{HOMEBREW_PREFIX}/opt/ccache"
},
"condition": {
"type": "equals",
"lhs": "${hostSystemName}",
"rhs": "Darwin"
}
}

Conclusion

  • Toolchains focus on compiler and tool setup for specific environments.
  • Presets provide reusable configuration sets for different build scenarios.
  • Build scripts define the project's structure and build rules using CMake commands.
  • Use toolchains for cross-compilation and non-standard tool setups.
  • Use presets for managing multiple build configurations and sharing settings.
  • Use build scripts to define the project's build structure and rules.

Further Reading

Explore other opinions and insights on CMake presets:

Explore IDE integrations and their support for CMake presets:

Are you ready to optimize your CI pipelines and enhance your development workflow with CMake presets? Let the journey begin!