Introduction

This is a repository that is designed to make it easy to set up and maintain other C++-centric projects, by centralizing code (mostly CMake code) that runs CI.

Unlike some other projects of this nature, it is not designed for use as a template repository. Rather, a few lines of CMake code will set up a dependency on this repository that can then be easily kept up to date.

This repository provides functionality in three areas:

  • Github workflows

  • CMake utility functions

  • CMake toolchains

Getting started

First, add get_cpm.cmake to your project’s repository. Then, make your CMakeLists.txt file look something like this at the top:

cmake_minimum_required(VERSION 3.27)

project(my_project)

include(cmake/get_cpm.cmake)
cpmaddpackage("gh:intel/cicd-repo-infrastructure#abc123")

Auto updates

If you rely on this repository at a branch name rather than a specific tag or hash, then what you probably intended was to pin to the head of that branch. In that case, the first thing this repository does is check that it is actually at the head of the upstream branch, and if not, update. This behaviour is controlled with the INFRA_SELF_UPDATE CMake option, which is ON by default.

For example, if your CMakeLists.txt says:

cpmaddpackage("gh:intel/cicd-repo-infrastructure#dev")

When you run CMake the first time, this repository is downloaded (at the head of the dev branch) and stored in the CPM cache.

The second time you run CMake, this repository is already in the cache. This is fine if you are pinning to an immutable hash, but if you are using a branch as above, the upstream branch may have moved on. This is the case where an auto-update check is performed, to keep the downloaded repository at the head of dev.

Auto-updates will not occur in the following circumstances:

  • INFRA_SELF_UPDATE is OFF

  • This repository is not fetched with CPM

  • You’re using a filesystem copy rather than from a remote repository

CMake options

To control the action of this repository, you can set several CMake options (in the initial call to cpmaddpackage to pull in this repository as a dependency). All options default to ON.

INFRA_PROVIDE_GITHUB_WORKFLOWS

When ON, this repository provides .yml files inside the .github directory.

INFRA_PROVIDE_CLANG_FORMAT

When ON, this repository provides a .clang-format file at the root level.

INFRA_PROVIDE_CLANG_TIDY

When ON, this repository provides a .clang-tidy file at the root level.

INFRA_PROVIDE_CMAKE_FORMAT

When ON, this repository provides a .cmake-format.yaml file at the root level.

INFRA_PROVIDE_PRESETS

When ON, this repository provides a CMakePresets.json file at the root level, and a toolchains directory.

INFRA_PROVIDE_MULL

When ON, this repository provides a mull.yml file at the root level.

INFRA_PROVIDE_PYTEST_REQS

When ON, this repository provides a requirements.txt file at the root level. When used with pip install -r requirements.txt this provides the packages sufficient to run python tests.

INFRA_PROVIDE_GITIGNORE

When ON, this repository provides a .gitignore file at the root level. It contains some commonly-used entries as well as lines to exclude the items (if any) provided by this repo.

INFRA_USE_SYMLINKS

When ON, the items provided will be symbolic links into this repo’s downloaded directory. When OFF, the items will be copied.

Note
Files inside the .github directory and the .gitignore file are always copied, never symlinked - otherwise they don’t work.

Dependency support

We use CPM as a mechanism for fetching and maintaining dependencies. To easily pull in dependencies for your project, use add_versioned_package:

add_versioned_package("gh:fmtlib/fmt#10.1.1")

This is a wrapper around cpmaddpackage that supports the same arguments, but adds some features.

Logging the dependency tree

When working with several projects that use CPM to pull in dependencies, one ends up with a tree of dependencies, and the classical problem of dependencies can arise: if project A depends on projects B and C, and projects B and C each depend on project X but at different versions, CPM downloads X the first time it is asked. At the second time of asking, X does not get redownloaded, leading to a possible dependency issue.

add_versioned_package helps track down these issues by logging the dependency tree in the file cpm_dependencies.txt, in the CMake build directory. This shows exactly which version of which dependency was asked for, by whom, and in what order. A clashing dependency can then hopefully be resolved by asking for a more recent version earlier in the process.

Whether or not the dependency tree is logged is controlled by the CMake option LOG_CPM_DEPENDENCIES which is ON by default.

Version satisfaction

add_versioned_package is a bit smarter than cpmaddpackage about detecting a version number and a git hash, and trying to decide whether a dependency is satisfied.

For version numbers, CMake version comparisons are used according to the COMPARE argument to add_versioned_package (LESS, GREATER, EQUAL, LESS_EQUAL or GREATER_EQUAL). The default is GREATER_EQUAL: if you’re asking for a particular version, and a greater or equal version was already fetched, the dependency is satisfied.

For example:

# Our project depends on packages A and B
add_versioned_package("gh:projectA/A#1.0.0")
add_versioned_package("gh:projectB/B#1.0.0")

# ...

# Somewhere inside package A: a dependency on fmtlib at version 10.1.1
add_versioned_package("gh:fmtlib/fmt#10.1.1")

# Somewhere inside package B: a dependency on fmtlib at version 9.1.0
add_versioned_package("gh:fmtlib/fmt#9.1.0")

In this case, when project B asks for fmtlib, it has already been fetched by project A, and at a higher version; B’s dependency is considered already satisfied. If the dependencies were fetched in the opposite order, an error would be reported.

For git hashes, a dependency is considered satisfied if that hash appears in the ancestry of the already downloaded version. This applies whether the downloaded version was a semantic version like 10.1.1, or a git hash itself. As long as the requested hash is somewhere in the historical line, it’s considered satisfied.

CPM recipes

Many projects use CMake. Many projects use CMake in different ways, and not all of them work well with naive CPM usage. For popular projects that need some careful handling, CPM recipes are provided, which encapsulate useful options passed to add_versioned_package.

boost_sml_recipe("1.1.9")

Github workflows

When the INFRA_PROVIDE_GITHUB_WORKFLOWS option is ON, three files will be copied into the .github directory:

Unit tests workflow

The unit tests workflow provides a job matrix that builds the cartesian product of:

  • Clang 14 through 18

  • GCC 12 through 13

  • libstdc+​+ and libc+​+

  • C++20

  • Debug builds

It also builds a quality check; runs unit tests with sanitizers; and runs unit tests with valgrind.

Documentation workflow

This is a simple workflow that uses AsciiDoctor to build and publish documentation as Github Pages. The documentation you are reading is authored with AsciiDoctor and published with this workflow.

To set up documentation for a project, use the add_docs CMake function. Its argument is the directory containing the docs.

To put diagrams in documentation, use either Ditaa (bundled with asciidoctor-diagram) or Mermaid (also installed by the workflow).

An example of a Mermaid diagram specified in AsciiDoctor:

[mermaid, format="svg"]
----
graph LR
    A[Square Rect] -- Link text --> B((Circle))
    A --> C(Round Rect)
    B --> D{Rhombus}
    C --> D
----

And its output:

Diagram
Note
Although CMake will build docs in any directory, the Github workflow that publishes the documentation assumes that documentation source files are in /docs, and any static files (diagrams etc) are assumed to be in /docs/static.
add_docs(docs)

This will set up a CMake target called docs which builds the AsciiDoctor documentation found in the docs directory. (The docs argument here is the directory, not the target name.)

Note
The relevant parts of AsciiDoctor must be installed on the system! This happens automatically with CI, but not for a local build.
Note
Because constructing the docs target globs the files, an added file won’t be included until CMake is re-run.

In order to publish documentation successfully this way, your repository settings must allow publishing pages with a workflow.

Dependabot

Dependabot is a workflow that keeps dependencies up to date. It is chiefly useful with git submodule dependencies; not with dependencies pulled in by CPM.

Linting and formatting

This repository provides support for linting and formatting code and ensuring that it stays that way.

clang-format

If the INFRA_PROVIDE_CLANG_FORMAT option is ON, a .clang-format file will be provided.

To use clang-format from CMake, we depend on Format.cmake. This results in three CMake targets for clang-format:

  • clang-format

  • check-clang-format

  • fix-clang-format

Of these, the clang-format target is actually the least used. It runs clang-format on each file in the repository, with the results going to stdout. This is not typically very useful.

The check-clang-format target is used by CI builds: it exits with an error if any file requires formatting, i.e. if any file differs after clang-format runs.

The fix-clang-format target is the most useful for developers: it uses clang-format to format files in-place.

Note
fix-clang-format won’t change files that are already changed in git’s working tree. Robotic formatting changes should be applied in a separate commit for easy review.
Note
For preference, we’ll find the clang-format that exists alongside the compiler being used.

cmake-format

If the INFRA_PROVIDE_CMAKE_FORMAT option is ON, a .cmake-format.yaml file will be provided.

As with clang-format, Format.cmake provides three targets for cmake-format:

  • cmake-format

  • check-cmake-format

  • fix-cmake-format

black

Python code is formatted using black. There are two targets with the appropriate behavior:

  • check-black-format

  • fix-black-format

Note
Like fix-clang-format, fix-black-format won’t change files that are already changed in git’s working tree.

clang-tidy

If the INFRA_PROVIDE_CLANG_TIDY option is ON, a .clang-tidy file will be provided.

CMake has built-in support: to use clang-tidy with a cmake target, set the CXX_CLANG_TIDY property on it.

However, if you have a header-only library, this repository provides a clang_tidy_interface function that makes it easy to lint header files.

clang_tidy_interface(my_target)

This finds all the headers in the given target, and for each one, it creates an empty .cpp file that does nothing but #include that header. It creates a separate library target just for that generated .cpp file, and sets the CXX_CLANG_TIDY property on that library target.

Each such generated target is then rolled up into a single clang-tidy target.

The upshot of this is that each header is linted correctly. It also has the side effect of checking that each header can be included on its own.

The way that clang_tidy_interface works depends on the target properties. If target_sources is used with FILE_SET, clang_tidy_interface finds the headers using that method. Otherwise — when target_include_directories is used — clang_tidy_interface globs the headers in the include directories.

Note
If target_include_directories is used to specify a target’s headers, adding a header file won’t clang-tidy it until CMake is re-run.

You can still take advantage of clang_tidy_interface even if not all your code is linting cleanly, by providing exclusions:

clang_tidy_interface(
  TARGET my_target
  EXCLUDE_DIRS mylib/A mylib/B
  EXCLUDE_FILES mylib/file.hpp
  EXCLUDE_FILESETS exclusions)

In particular the EXCLUDE_FILESETS argument can be used together with target_sources, separating excluded headers into a separate FILE_SET.

Note
clang-tidy is only a useful target when building with a clang toolchain. If you are not building with clang, the clang-tidy target will do nothing.
Note
As with clang-format, for preference, we’ll find the clang-tidy that exists alongside the compiler being used.

Enabled clang-tidy checks

The following clang-tidy check categories are enabled:

  • bugprone-*

  • clang-diagnostic-*

  • clang-analyzer-*

  • cppcoreguidelines-*

  • misc-*

  • modernize-*

  • performance-*

  • portability-*

  • readability-*

In addition, the following specific checks are enabled:

The following specific checks are disabled because they are aliases for other checks, and clang-tidy does not deduplicate them:

The following checks are disabled for specific reasons:

It is likely in the future that more clang-tidy checks will be enabled.

mypy

Python linting is available using mypy. To lint python files, call mypy_lint:

mypy_lint(FILES file1.py file2.py)

And then building the mypy-lint target runs mypy against these files.

The quality target

The quality target encompasses other targets:

  • check-clang-format

  • check-cmake-format

  • clang-tidy

  • check-black-format

  • mypy-lint

This is a convenient target to build on the command-line to check that CI will pass. And any formatting failures can be fixed up by building the fix-clang-format, fix-cmake-format, and fix-black-format targets.

Note
The quality job that is run by CI always uses the latest clang version enabled in CI. Sometimes this can disagree with what is run locally, if you have reason to be building locally with an older supported toolchain. For example, if you build locally with clang-15, but CI runs clang-18, you are likely to get minor differences of formatting or linting that cause CI failures. Use the latest tools for best quality!

The ci-quality and *-branch-diff targets

Because linters can be somewhat expensive to run on a whole codebase, alternative targets for CI lint only what changed in a pull request.

When the environment variable PR_TARGET_BRANCH is set to main (or any other branch that a PR will be merged into), clang-tidy-branch-diff builds the clang-tidy targets for the files which have changed between the PR branch and the target branch. Likewise mypy-lint-branch-diff does the same thing for the mypy-lint targets. The ci-quality target depends on these "diff" targets rather than on the corresponding "full" targets.

It is fairly easy to set up CI to do this, but note that both branches must be fetched. See the quality_checks_pass job in .github/workflows/unit_tests.yml for an example.

Libraries and warnings

This repository provides an INTERFACE library called warnings that, when linked with, applies warnings to the code.

One of the problems commonly encountered with using strict warnings is the problem of dependencies: you don’t want to enforce warnings on third-party libraries. Any warnings that arise from third-party libraries we typically want to ignore; i.e. we want to treat that library as a system library. To do this, we can use target_link_libraries_system, for example:

add_executable(my_app src/main.cpp)
target_link_libraries(my_app PRIVATE my_lib warnings)
target_link_libraries_system(my_app PRIVATE fmt::fmt-header-only)

This is also used internally by functions like add_unit_test to silence warnings in unit tests frameworks.

This repository also provides an INTERFACE library called profile-compilation that, when linked with, passes profiling flags to the compiler, so that slow compilations can be tackled.

add_executable(my_app src/main.cpp)
target_link_libraries(my_app PRIVATE my_lib profile-compilation)

Testing

This repository provides CMake functions to make defining tests easy.

Defining unit tests

add_unit_test(
  my_test
  CATCH2
  FILES my_test.cpp
  LIBRARIES my_lib)

add_unit_test is a basic function for defining unit tests. Four unit test framework options are supported (according to the given argument):

RapidCheck is also provided for property-based testing; it integrates with either Catch2 or GoogleTest (or GUnit, which is based on GoogleTest). See the RapidCheck documentation for how to use it with either of those frameworks.

The arguments to add_unit_test are:

  • test name

  • optionally, a test framework to use

  • FILES to be compiled

  • optionally, extra INCLUDE_DIRECTORIES to use

  • optionally, extra LIBRARIES to link with

  • optionally, extra SYSTEM_LIBRARIES to link with

  • optionally, NORANDOM

Note
By default, tests will run in a random order unless the NORANDOM argument is given.

It is possible to define a unit test without using any of the supported frameworks. In that case, just write your own test program, with main returning zero on test success and non-zero on test failure.

Python unit tests

add_unit_test(
  my_test
  PYTEST
  FILES my_test.py
  LIBRARIES my_lib
  EXTRA_DEPS my_header.hpp)

add_unit_test also supports running tests using pytest. In this case the include paths implied by LIBRARIES are passed to the test with the command line --include_dirs <paths>. And the command line options can be handled with pytest’s addoption hook.

Note
The EXTRA_DEPS argument allows python tests to specify extra dependencies that are not part of the CMake dependency tree. A conftest.py file in the same directory as any of the FILE​s is automatically discovered as such a dependency.
Note
Python tests use several testing packages; these can be installed using requirements.txt which is provided through the INFRA_PROVIDE_PYTEST_REQS option.

Fuzz tests

add_fuzz_test(
  my_test
  FILES my_test.cpp
  LIBRARIES my_lib)

add_fuzz_test is a basic function for defining tests that use Google’s fuzztest. GTEST and rapidcheck macros are also available with these tests.

Note
Fuzz tests are enabled only when the compiler is Clang.

add_fuzz_test takes the same optional arguments as add_unit_test.

BDD feature tests

GUnit also provides for BDD-style "feature tests", which define tests in a Cucumber/Gherkin syntax feature file, and define steps in the C++.

add_feature_test(
    my_feature_test
    FILES
    my_steps.cpp
    FEATURE
    my_test.feature
    LIBRARIES
    my_lib
    warnings)

add_feature_test takes the same optional arguments as add_unit_test.

Mutation tests

When compiling with clang, any C++ test executable can be used to create a mutation test using mull.

add_unit_test(
  my_test
  CATCH2
  FILES my_test.cpp
  LIBRARIES my_lib)

add_mull_test(my_test)

The version of mull used must match the version of clang used. Arguments to add_mull_test are:

  • EXCLUDE_CTEST - to optionally exclude the test from the ctest suite

  • PLUGIN_DIR - to provide the location of mull-ir-frontend-<version>

  • RUNNER_DIR - to provide the location of mull-runner-<version>

  • RUNNER_ARGS - for any additional arguments to be passed to mull-runner-<version>

PLUGIN_DIR and RUNNER_DIR may be omitted if they reside in /usr/lib and on the path respectively. In that case, cmake will find them and report that it has.

For more information on mutation tests, see the mull documentation.

Compilation failure tests

When writing compile-time code, it’s often useful to check compilation failures to test that for example library users get a clear help message. To do that, use add_compile_fail_test:

add_compile_fail_test(my_fail_test.cpp LIBRARIES my_lib)

Compilation failure tests consist of a single source file and are unrelated to testing frameworks. The source file can define what is expected as a message with a comment:

// EXPECT: FLAGRANT SYSTEM ERROR

If no EXPECT comment is found, by default we will expect a static_assert to fire.

Note
Compilation failure tests are not part of the all target, for obvious reasons. But they are set up as tests that are designed to fail; they can be run with the test target or by ctest.
Note
Because ctest information is updated at CMake time, changing the EXPECT comment of a test requires re-running CMake to update what is expected.

add_compile_fail_test takes the same optional arguments as add_unit_test.

Sanitizers

The sanitizers library works similarly to the warnings library, but provides compiler command-line options that enable various sanitizers.

Which sanitizers are turned on is specified at CMake time by the environment variable SANITIZERS.

$ SANITIZERS=undefined cmake --preset=clang

When a sanitizer is set like this, any targets created with add_unit_test or add_feature_test will use the sanitizer flags. The unit_tests workflow runs tests with (separately) the undefined behavior, address and thread sanitizers.

Valgrind

CMake has built-in support for valgrind; using ctest with the -T memcheck option runs unit tests with valgrind. This is also done by the unit_tests workflow.

Test code coverage

To get code coverage reports, use the COVERAGE argument with add_unit_test.

add_unit_test(
  my_test
  CATCH2
  COVERAGE
  FILES my_test.cpp
  LIBRARIES my_lib)

This will generate a target that will produce a test coverage report in <build directory>/coverage.

$ cmake --build build -t coverage_report_my_test
# coverage report is in build/coverage/my_test.coverage_report.txt

If multiple tests generate coverage reports, the rolled-up report can be built using the cpp_coverage_report target:

$ cmake --build build -t cpp_coverage_report
# combined coverage report is in build/coverage_report.txt

The COVERAGE argument can also be used with add_feature_test or add_fuzz_test.

Note
Test coverage uses LLVM’s source-based code coverage tooling, so is only available when using a clang toolchain, and only for C++ (not Python) tests.

Benchmarking

This repository provides CMake functions to make defining benchmarks easy.

Defining benchmarks

add_benchmark(
  my_benchmark
  NANO
  FILES my_benchmark.cpp
  LIBRARIES my_lib)

add_benchmark is a basic function for defining benchmarks. At the moment, the only benchmark framework supported is NANO.

add_benchmark takes the same optional arguments as add_unit_test.

Note
benchmark targets are compiled with -O3 -march=native.

Toolchains and presets

If the INFRA_PROVIDE_PRESETS option is ON, a CMakePresets.json file will be provided, together with toolchain files.

Toolchain files are provided for gcc and clang. Where to find the compilers can be altered with the TOOLCHAIN_ROOT environment variable.

$ TOOLCHAIN_ROOT=/usr/lib/llvm-18 cmake --preset=clang

If needed, the GCC_TOOLCHAIN_ROOT environment variable can be used with a clang build to provide the --gcc-toolchain argument to the compiler.

The CXX_STDLIB environment variable can be used to alter which standard library is used (libstdc+​+ or libc+​+).