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
isOFF
-
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:
See more examples at https://mermaid.js.org/syntax/examples.html.
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:
-
bugprone-narrowing-conversions aliases cpp-coreguidelines-narrowing-conversions
-
cppcoreguidelines-avoid-c-arrays aliases modernize-avoid-c-arrays
-
cppcoreguidelines-avoid-magic-numbers aliases readability-magic-numbers
-
cppcoreguidelines-c-copy-assignment-signature aliases misc-unconventional-assignment-operator
-
cppcoreguidelines-explicit-virtual-functions aliases modernize-use-override
-
cppcoreguidelines-macro-to-enum aliases modernize-macro-to-enum
-
cppcoreguidelines-noexcept-destructor aliases performance-noexcept-destructor
-
cppcoreguidelines-noexcept-move-operations aliases performance-noexcept-move-constructor
-
cppcoreguidelines-noexcept-swap aliases performance-noexcept-swap
-
cppcoreguidelines-non-private-member-variables-in-classes aliases misc-non-private-member-variables-in-classes
-
cppcoreguidelines-use-default-member-init aliases modernize-use-default-member-init
The following checks are disabled for specific reasons:
-
bugprone-easily-swappable-parameters - may be enabled someday, but currently too onerous.
-
cppcoreguidelines-avoid-non-const-global-variables - the nature of embedded work makes this check ill-conceived.
-
cppcoreguidelines-missing-std-forward - this check misdiagnoses some common things.
-
cppcoreguidelines-pro-bounds-pointer-arithmetic - may be enabled someday, but currently too onerous.
-
misc-include-cleaner - warns on omnibus headers.
-
misc-non-private-member-variables-in-classes - public variables don’t contribute to class invariants.
-
modernize-concat-nested-namespaces - it’s a style choice.
-
readability-identifier-length - generic code uses lots of short identifiers.
-
readability-identifier-naming - one of the most expensive checks; not worth the cost.
-
readability-magic-numbers - the nature of embedded work makes this too onerous.
-
readability-named-parameter - it’s a style choice.
-
readability-qualified-auto - it’s a style choice.
-
readability-redundant-inline-specifier -
inline
is mostly, but not only for the linker. -
readability-uppercase-literal-suffix - it’s a style choice.
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 ofmull-ir-frontend-<version>
-
RUNNER_DIR
- to provide the location ofmull-runner-<version>
-
RUNNER_ARGS
- for any additional arguments to be passed tomull-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++).