This episode covers modern C++ tooling: CMake workflows, continuous integration with GitHub Actions and GitLab CI, static analysis and code formatting with clang-format, as well as reproducible builds and dependency management.

Great code means nothing without a process that keeps it high quality. In real projects, you don't wait for humans to check everything — tooling takes over: CMake builds correctly on all platforms, CI runs tests and analysis automatically, formatters keep consistency, and dependency management keeps builds reproducible.
Episode 19 covers modern tooling: correct CMake workflows, continuous integration pipelines with GitHub Actions and GitLab CI, clang-format and clang-tidy for code consistency, as well as reproducible builds and dependency management.
Modern CMake no longer uses global variables. Each target declares its own needs with target_compile_features and target_link_libraries. This target-based approach makes dependencies propagate transparently:
cat > CMakeLists.txt <<'EOF'
cmake_minimum_required(VERSION 3.20)
project(workspace LANGUAGES CXX)
add_library(utilitas STATIC util.cpp)
target_compile_features(utilitas PUBLIC cxx_std_20)
target_include_directories(utilitas PUBLIC .)
add_executable(app main.cpp)
target_link_libraries(app PRIVATE utilitas)
EOF
cmake -S . -B build -G Ninja
cmake --build build
ctest --test-dir buildcmake -S . -B build -G Ninja configures the project with the Ninja generator, which is faster than Make. target_compile_features(utilitas PUBLIC cxx_std_20) sets C++20 for the library and everything that links against it. ctest --test-dir build runs the registered tests.
Use CMake presets to standardize how the team builds. Presets define debug and release configurations in a single CMakePresets.json file, so everyone uses the same commands — without memorizing flags:
{
"version": 3,
"configurePresets": [
{
"name": "debug",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/debug",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"CMAKE_CXX_FLAGS": "-fsanitize=address"
}
}
]
}The debug preset above enables AddressSanitizer automatically. Calling cmake --preset debug then cmake --build --preset debug produces a consistent build for every team member.
CI runs builds, tests, and analysis on a server every time there's a change. A GitHub Actions pipeline for C++ usually uses a matrix of compilers and operating systems:
name: cpp-ci
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: sudo apt install -y ninja-build
- run: cmake -S . -B build -G Ninja
- run: cmake --build build
- run: ctest --test-dir build --output-on-failurecmake --build build builds the project in CI, and ctest --test-dir build --output-on-failure runs the tests with failure reports. The pipeline runs for every push and pull request, catching regressions before code is merged.
GitLab CI uses .gitlab-ci.yml with a similar stages concept. The key to success in both: builds and tests must run without manual interaction, and build artifacts should be uploaded so they can be downloaded for further analysis.
clang-format automatically formats code following the rules set in .clang-format. Every team member uses an identical format, so diffs stay clean and reviews focus on logic instead of whitespace differences:
clang-format -i main.cpp util.cpp
clang-tidy main.cpp -- -std=c++20
cppcheck --enable=all main.cppclang-format -i rewrites files according to the configuration. clang-tidy and cppcheck — already covered in episode 11 — detect bugs and style issues. A common rule for formatter configuration: commit the .clang-format file so everyone uses the same one.
Inconsistent formatting can block merges. Add a CI step that checks whether code is already formatted:
clang-format --dry-run --Werror main.cppclang-format --dry-run --Werror only reports files that don't match the format without changing them, and returns an error code. The pipeline rejects changes that aren't formatted — this keeps consistency without relying on individual discipline.
A reproducible build means two builds of the same code produce identical binaries. Factors that break reproducibility: build timestamps, absolute paths, and environment variations. CMake helps through fixed CMAKE_CXX_FLAGS and sources stored together:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_FLAGS="-std=c++20 -O2 -ffile-prefix-map=$PWD=."
cmake --build build-ffile-prefix-map=$PWD=. replaces absolute paths in metadata with relative ones, removing one source of inconsistency. For similar reasons, CI should use version-pinned container images.
Managing C++ dependencies isn't as easy as in other languages. Vendor with git submodules or FetchContent for small dependencies, and package managers like vcpkg or Conan for large ones. FetchContent lets you put a dependency directly in a CMakeLists:
include(FetchContent)
FetchContent_Declare(json
GIT_REPOSITORY https://github.com/nlohmann/json
GIT_TAG v3.11.3)
FetchContent_MakeAvailable(json)FetchContent_MakeAvailable(json) downloads and builds the nlohmann JSON library at the fixed version v3.11.3. Pinning versions (not branches) is a requirement for reproducible builds.
Tip
Use vcpkg manifest mode (vcpkg.json) or a Conan lockfile so dependency versions are pinned for the whole team and CI. Episode 20 will cover library portability further.
Here's what to take away:
target_compile_features and target_link_libraries.In the next episode, episode 20, we'll discuss cross-platform development — writing portable code for Windows, Linux, and macOS, platform-specific abstractions and conditional compilation, cross-compilation for different target architectures, as well as vendor-neutral library and dependency portability.