Using GitHub Actions with C++ and CMake

I bring to your attention the translation of the article “Using GitHub Actions with C ++ and CMake” by Cristian Adam, written about three years ago. During this time, GitHub Actions has seen many improvements, and some of the tricks in the article may seem like bicycle building. However, it remains a good introductory overview.


In this post, I want to show the GitHub Actions configuration file for a C++ project using CMake.

GitHub Actions this is the CI/CD infrastructure provided by GitHub. GitHub Actions currently offers the following virtual machines (runners):

Virtual environment

YAML workflow name

Windows Server 2022

windows-latest

Ubuntu 20.04

ubuntu-latest or ubuntu-20.04

Ubuntu 18.04

ubuntu-18.04

macOS Catalina 10.15

macos-latest

Each virtual machine has the same available hardware resources:

Each workflow job can run up to 6 hours.

Unfortunately, when I enabled GitHub Actions in a C++ project, I was presented with this workflow:

./configure
make
make check
make distcheck

It’s a little different than what can be used with CMake.

hello world

I want to build a traditional C++ test application:

#include <iostream>

int main()
{
  std::cout << "Hello world\n";
}

With the following CMake project:

cmake_minimum_required(VERSION 3.16)

project(main)

add_executable(main main.cpp)

install(TARGETS main)

enable_testing()
add_test(NAME main COMMAND main)

TL;DR see project at GitHub.

Build Matrix

I started with the following build matrix:

name: CMake Build Matrix

on: [push]

jobs:
  build:
    name: ${ { matrix.config.name } }
    runs-on: ${ { matrix.config.os } }
    strategy:
      fail-fast: false
      matrix:
        config:
        - {
            name: "Windows Latest MSVC", artifact: "Windows-MSVC.tar.xz",
            os: windows-latest,
            build_type: "Release", cc: "cl", cxx: "cl",
            environment_script: "C:/Program Files (x86)/Microsoft Visual Studio/2019/Enterprise/VC/Auxiliary/Build/vcvars64.bat"
          }
        - {
            name: "Windows Latest MinGW", artifact: "Windows-MinGW.tar.xz",
            os: windows-latest,
            build_type: "Release", cc: "gcc", cxx: "g++"
          }
        - {
            name: "Ubuntu Latest GCC", artifact: "Linux.tar.xz",
            os: ubuntu-latest,
            build_type: "Release", cc: "gcc", cxx: "g++"
          }
        - {
            name: "macOS Latest Clang", artifact: "macOS.tar.xz",
            os: macos-latest,
            build_type: "Release", cc: "clang", cxx: "clang++"
          }

Fresh CMake and Ninja

On the page installed software virtual machines, we see that CMake is everywhere, but in different versions:

Virtual environment

CMake version

Windows Server 2019

3.16.0

Ubuntu 18.04

3.12.4

macOS Catalina 10.15

3.15.5

This means that you will need to limit the minimum version of CMake to 3.12 or update CMake.

CMake 3.16 supports header precompilation and Unity Buildswhich help reduce build time.

Since CMake and Ninja have repositories on GitHub, I decided to download the required releases from GitHub.

I used CMake to write the script because VMs use by default peculiar to them scripting language (bash for Linux and powershell for Windows). CMake can execute processes, upload files, extract archives, and do many other useful things.

- name: Download Ninja and CMake
  id: cmake_and_ninja
  shell: cmake -P {0}
  run: |
    set(ninja_version "1.9.0")
    set(cmake_version "3.16.2")

    message(STATUS "Using host CMake version: ${CMAKE_VERSION}")

    if ("${ { runner.os } }" STREQUAL "Windows")
      set(ninja_suffix "win.zip")
      set(cmake_suffix "win64-x64.zip")
      set(cmake_dir "cmake-${cmake_version}-win64-x64/bin")
    elseif ("${ { runner.os } }" STREQUAL "Linux")
      set(ninja_suffix "linux.zip")
      set(cmake_suffix "Linux-x86_64.tar.gz")
      set(cmake_dir "cmake-${cmake_version}-Linux-x86_64/bin")
    elseif ("${ { runner.os } }" STREQUAL "macOS")
      set(ninja_suffix "mac.zip")
      set(cmake_suffix "Darwin-x86_64.tar.gz")
      set(cmake_dir "cmake-${cmake_version}-Darwin-x86_64/CMake.app/Contents/bin")
    endif()

    set(ninja_url "https://github.com/ninja-build/ninja/releases/download/v${ninja_version}/ninja-${ninja_suffix}")
    file(DOWNLOAD "${ninja_url}" ./ninja.zip SHOW_PROGRESS)
    execute_process(COMMAND ${CMAKE_COMMAND} -E tar xvf ./ninja.zip)

    set(cmake_url "https://github.com/Kitware/CMake/releases/download/v${cmake_version}/cmake-${cmake_version}-${cmake_suffix}")
    file(DOWNLOAD "${cmake_url}" ./cmake.zip SHOW_PROGRESS)
    execute_process(COMMAND ${CMAKE_COMMAND} -E tar xvf ./cmake.zip)

    # Save the path for other steps
    file(TO_CMAKE_PATH "$ENV{GITHUB_WORKSPACE}/${cmake_dir}" cmake_dir)
    message("::set-output name=cmake_dir::${cmake_dir}")

    if (NOT "${ { runner.os } }" STREQUAL "Windows")
      execute_process(
        COMMAND chmod +x ninja
        COMMAND chmod +x ${cmake_dir}/cmake
      )
    endif()

Tuning step

Now that I have CMake and Ninja, all I have to do is set up the project like this:

- name: Configure
  shell: cmake -P {0}
  run: |
    set(ENV{CC} ${ { matrix.config.cc } })
    set(ENV{CXX} ${ { matrix.config.cxx } })

    if ("${ { runner.os } }" STREQUAL "Windows" AND NOT "x${ { matrix.config.environment_script } }" STREQUAL "x")
      execute_process(
        COMMAND "${ { matrix.config.environment_script } }" && set
        OUTPUT_FILE environment_script_output.txt
      )
      file(STRINGS environment_script_output.txt output_lines)
      foreach(line IN LISTS output_lines)
        if (line MATCHES "^([a-zA-Z0-9_-]+)=(.*)$")
          set(ENV{${CMAKE_MATCH_1} } "${CMAKE_MATCH_2}")
        endif()
      endforeach()
    endif()

    file(TO_CMAKE_PATH "$ENV{GITHUB_WORKSPACE}/ninja" ninja_program)

    execute_process(
      COMMAND ${ { steps.cmake_and_ninja.outputs.cmake_dir } }/cmake
        -S .
        -B build
        -D CMAKE_BUILD_TYPE=${ { matrix.config.build_type } }
        -G Ninja
        -D CMAKE_MAKE_PROGRAM=${ninja_program}
      RESULT_VARIABLE result
    )
    if (NOT result EQUAL 0)
      message(FATAL_ERROR "Bad exit status")
    endif()

I have set environment variables CC and CXXand for MSVC I had to execute a script vcvars64.batget all environment variables and set them for the running CMake script.

Build step

The build step involves running CMake with the parameter --build:

- name: Build
  shell: cmake -P {0}
  run: |
    set(ENV{NINJA_STATUS} "[%f/%t %o/sec] ")

    if ("${ { runner.os } }" STREQUAL "Windows" AND NOT "x${ { matrix.config.environment_script } }" STREQUAL "x")
      file(STRINGS environment_script_output.txt output_lines)
      foreach(line IN LISTS output_lines)
        if (line MATCHES "^([a-zA-Z0-9_-]+)=(.*)$")
          set(ENV{${CMAKE_MATCH_1} } "${CMAKE_MATCH_2}")
        endif()
      endforeach()
    endif()

    execute_process(
      COMMAND ${ { steps.cmake_and_ninja.outputs.cmake_dir } }/cmake --build build
      RESULT_VARIABLE result
    )
    if (NOT result EQUAL 0)
      message(FATAL_ERROR "Bad exit status")
    endif()

To see the compilation speed on different virtual environments, I set the variable NINJA_STATUS.

For MSVC variables I used a script environment_script_output.txtobtained in the configuration step.

Test run step

This step calls ctest with passing the number of processor cores through the argument -j:

- name: Run tests
  shell: cmake -P {0}
  run: |
    include(ProcessorCount)
    ProcessorCount(N)

    execute_process(
      COMMAND ${ { steps.cmake_and_ninja.outputs.cmake_dir } }/ctest -j ${N}
      WORKING_DIRECTORY build
      RESULT_VARIABLE result
    )
    if (NOT result EQUAL 0)
      message(FATAL_ERROR "Running tests failed!")
    endif()

Installation, Packing and Downloading Steps

These steps include running CMake with --installsubsequent call to CMake to create the archive tar.xz and loading the archive as a build artifact.

- name: Install Strip
  run: ${ { steps.cmake_and_ninja.outputs.cmake_dir } }/cmake --install build --prefix instdir --strip

- name: Pack
  working-directory: instdir
  run: ${ { steps.cmake_and_ninja.outputs.cmake_dir } }/cmake -E tar cJfv ../${ { matrix.config.artifact } } .

- name: Upload
  uses: actions/upload-artifact@v1
  with:
    path: ./${ { matrix.config.artifact } }
    name: ${ { matrix.config.artifact } }

I didn’t use CMake as a scripting language for simple parameter calls to CMake, the default shells handle this just fine.

Release processing

When you tag a release in git, you also want the build artifacts to be attached to the release:

git tag -a v1.0.0 -m "Release v1.0.0"
git push origin v1.0.0

Below is the code for this, which will work if the git refpath contains tags/v:

release:
  if: contains(github.ref, 'tags/v')
  runs-on: ubuntu-latest
  needs: build

  steps:
  - name: Create Release
    id: create_release
    uses: actions/create-release@v1.0.0
    env:
      GITHUB_TOKEN: ${ { secrets.GITHUB_TOKEN } }
    with:
      tag_name: ${ { github.ref } }
      release_name: Release ${ { github.ref } }
      draft: false
      prerelease: false

  - name: Store Release url
    run: |
      echo "${ { steps.create_release.outputs.upload_url } }" > ./upload_url

  - uses: actions/upload-artifact@v1
    with:
      path: ./upload_url
      name: upload_url

publish:
  if: contains(github.ref, 'tags/v')
  name: ${ { matrix.config.name } }
  runs-on: ${ { matrix.config.os } }
  strategy:
    fail-fast: false
    matrix:
      config:
      - {
          name: "Windows Latest MSVC", artifact: "Windows-MSVC.tar.xz",
          os: ubuntu-latest
        }
      - {
          name: "Windows Latest MinGW", artifact: "Windows-MinGW.tar.xz",
          os: ubuntu-latest
        }
      - {
          name: "Ubuntu Latest GCC", artifact: "Linux.tar.xz",
          os: ubuntu-latest
        }
      - {
          name: "macOS Latest Clang", artifact: "macOS.tar.xz",
          os: ubuntu-latest
        }
  needs: release

  steps:
  - name: Download artifact
    uses: actions/download-artifact@v1
    with:
      name: ${ { matrix.config.artifact } }
      path: ./

  - name: Download URL
    uses: actions/download-artifact@v1
    with:
      name: upload_url
      path: ./
  - id: set_upload_url
    run: |
      upload_url=`cat ./upload_url`
      echo ::set-output name=upload_url::$upload_url

  - name: Upload to Release
    id: upload_to_release
    uses: actions/upload-release-asset@v1.0.1
    env:
      GITHUB_TOKEN: ${ { secrets.GITHUB_TOKEN } }
    with:
      upload_url: ${ { steps.set_upload_url.outputs.upload_url } }
      asset_path: ./${ { matrix.config.artifact } }
      asset_name: ${ { matrix.config.artifact } }
      asset_content_type: application/x-gtar

It looks complicated, but it’s necessary because actions/create-release can be called once, otherwise this action will end with an error. This is discussed in issue #14 and issue #27.

Although you can use the workflow up to 6 hours, the token secrets.GITHUB_TOKEN valid one hour. You can create a personal token or manually upload artifacts to a release. Details in discussion GitHub communities.

Conclusion

Enabling GitHub Actions in your CMake project becomes easier if you create a file .github/workflows/build_cmake.yml with content from build_cmake.yml.

You can take a look at the GitHub Actions in Christian Adam’s project hello world github.

Original text published on page under license CCBY 4.0.