Packaging with GitHub Actions

Learn to automate the packaging process by integrating CPack with a multi-platform GitHub Actions workflow

Greg Filak
Published

In the last lesson, we learned how to use CPack to bundle our project's install-tree into a distributable package like a .zip archive. We can now run cpack --preset dev-zip on our local machine to generate a package.

Our CI pipeline already automates building and testing. The logical next step is to integrate our packaging step into this automated workflow. By the end of this lesson, our GitHub Actions workflow will not only verify our code but also automatically produce distributable packages for Windows, macOS, and Linux, making them available for download.

Automating Package Creation

This lesson builds on the GitHub Actions workflow we built . Our workflow checks out our code, sets up vcpkg, and then uses our CMake presets to configure, build, and run our tests on a single operating system:

.github/workflows/ci.yml

name: C++ CI

on:
  pull_request:
    branches: [ "main" ]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
    - name: Check out code
      uses: actions/checkout@v4

    - name: Set up vcpkg
      uses: lukka/run-vcpkg@v11

    - name: Configure CMake
      run: cmake --preset default

    - name: Build project
      run: cmake --build --preset default

    - name: Run tests
      run: ctest --preset default

Our goal is to add two new capabilities to this workflow:

  1. Run CPack after the build to create our package.
  2. Save the generated package file so we can download it after the CI run is complete.

Adding a CPack Step to the Workflow

This is the easiest step. Since our build already has this capability and CMake presets to support it, all we need to do is add a new run step that executes cpack using our preset.

For now, we'll also update our configure, build, and test steps to use the release presets, but we'll introduce a way to have our workflow run multiple configurations using a matrix strategy soon.

For packaging, we'll use the dev-zip preset we created in the :

.github/workflows/ci.yml

name: C++ CI

on:
  pull_request:
    branches: ["main"]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - name: Check out code
        uses: actions/checkout@v4

      - name: Set up vcpkg
        uses: lukka/run-vcpkg@v11

      - name: Configure CMake
        run: cmake --preset release

      - name: Build project
        run: cmake --build --preset release

      - name: Run tests
        run: ctest --preset release

      - name: Package
        run: cpack --preset dev-zip

When this step runs, CPack will execute and create the package file inside the build directory on the CI runner. If we look at the logs for this step in GitHub Actions, we should see the familiar output from CPack:

This is a great start, but we have an immediate problem: how do we download this package? Where is it even stored?

Saving the Package with Workflow Artifacts

Any files created on a CI runner are temporary. Once the job finishes, the virtual machine is destroyed, and all its files are lost. To preserve the output of a job, we need to use workflow artifacts.

An artifact is a file or a collection of files that are saved from a workflow run. Once a job is complete, you can view and download its artifacts from the workflow's summary page on GitHub.

Using the upload-artifact Action

The standard way to create an artifact is with the official actions/upload-artifact action. Let's add this as the final step in our job.

.github/workflows/ci.yml

name: C++ CI

on:
  pull_request:
    branches: ["main"]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - name: Check out code
        uses: actions/checkout@v4

      - name: Set up vcpkg
        uses: lukka/run-vcpkg@v11

      - name: Configure CMake
        run: cmake --preset release

      - name: Build project
        run: cmake --build --preset release

      - name: Run tests
        run: ctest --preset release

      - name: Package
        run: cpack --preset dev-zip

      - name: Upload Package Artifact
        uses: actions/upload-artifact@v4
        with:
          name: Greeter-Package-Linux
          path: build/Greeter-*-Development.zip

Let's break down the with block, which contains the inputs for this action:

  • name: This is the name the artifact will have on the GitHub UI. It's a good idea to make this descriptive, including the platform.
  • path: This tells the action which file(s) to upload. CPack generates filenames that include the version and architecture (e.g., Greeter-1.0.0-Linux.zip). Since this can change, we use a glob pattern (*) to match any file that starts with Greeter- and ends with -Development.zip in the build/ directory.

Now, the actions/upload-artifact will provide a link to download our package within its log output. Also, after our workflow completes, if we go to the "Summary" page for the run, we'll see a new "Artifacts" section with our package, ready to be downloaded:

Cross-Platform Packages using a Matrix Strategy

GitHub Actions has a useful feature called a strategy: matrix. It allows us to define a set of configurations, and GitHub will automatically create a separate job for each combination. As an example, let's use this to create builds and packages for three platforms - Linux, Windows, and macOS.

Let's refactor our ci.yml to use a matrix. We'll define a list of operating systems and let GitHub Actions run our build-and-test job in parallel on each one:

.github/workflows/ci.yml

name: C++ CI

on:
  pull_request:
    branches: ["main"]

jobs:
  build-and-test:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        
    runs-on: ${{ matrix.os }}
    
    steps:
      - name: Check out code
        uses: actions/checkout@v4

      - name: Set up vcpkg
        uses: lukka/run-vcpkg@v11

      - name: Configure CMake
        run: cmake --preset release

      - name: Build project
        run: cmake --build --preset release

      - name: Run tests
        run: ctest --preset release

      - name: Package
        run: cpack --preset dev-zip

      - name: Upload Package Artifact
        uses: actions/upload-artifact@v4
        with:
          name: Greeter-Package-${{ matrix.os }}
          path: build/Greeter-*-Development.zip

Let's examine the changes:

The strategy: matrix: os: [...] setting define a matrix with a single variable, os, with three values representing the list of runner images we want to use.

Then, instead of hardcoding a runner for the runs-on option, we use the special context syntax ${{ ... }} to access the os variable from the matrix.

We also use this variable in the name we pass to upload-artifact. This ensures each of our packages have a unique name, which will help us distinguish between them.

Now, when this workflow runs, GitHub will create three parallel jobs. The first job will run with matrix.os set to ubuntu-latest, the second with windows-latest, and the third with macos-latest.

The result is a parallel, multi-platform build from a single job definition:

This is why keeping our build scripts portable is important. If they're portable, everything will just work.

Once all of our builds are complete, our workflow summary page will show three downloadable packages - one from each of our parallel jobs:

More Examples

This example use of a matrix created an os variable, but we can use this technique to solve a wide range of problems. For example, we could create a matrix strategy to handle different build configurations, implemented by controlling which presets are used:

name: C++ CI

on:
  pull_request:
    branches: ["main"]

jobs:
  build-and-test:
    strategy:
      matrix:
        config: [debug, release]
    runs-on: ubuntu-latest
    steps:
      - name: Check out code
        uses: actions/checkout@v4

      - name: Set up vcpkg
        uses: lukka/run-vcpkg@v11

      - name: Configure CMake
        run: cmake --preset ${{ matrix.config }}

      - name: Build project
        run: cmake --build --preset ${{ matrix.config }}

      - name: Run tests
        run: ctest --preset ${{ matrix.config }}

      - name: Package
        run: cpack --preset ${{ matrix.config }}-dev-zip

      - name: Upload Package Artifact
        uses: actions/upload-artifact@v4
        with:
          name: Greeter-Package-ubuntu-latest
          path: build/Greeter-*-Development.zip

Our matrix strategy can also have multiple dimensions. This 3x2 matrix would spawn six jobs - one for every combination of os and config:

name: C++ CI

on:
  pull_request:
    branches: ["main"]

jobs:
  build-and-test:
    strategy:
      matrix: 
        os: [ubuntu-latest, windows-latest, macos-latest]
        config: [debug, release]
    runs-on: ${{ matrix.os }}
    steps:
      - name: Check out code
        uses: actions/checkout@v4

      - name: Set up vcpkg
        uses: lukka/run-vcpkg@v11

      - name: Configure CMake
        run: cmake --preset ${{ matrix.config }}

      - name: Build project
        run: cmake --build --preset ${{ matrix.config }}

      - name: Run tests
        run: ctest --preset ${{ matrix.config }}

      - name: Package
        run: cpack --preset ${{ matrix.config }}-dev-zip

      - name: Upload Package Artifact
        uses: actions/upload-artifact@v4
        with:
          name: Greeter-Package-${{ matrix.os }}-${{ matrix.config}} 
          path: build/Greeter-*-Development.zip

Summary

In this lesson, we've integrated our CPack configuration directly into our GitHub Actions workflow, automating the packaging process.

  • Automated Packaging: We added a run step to our CI workflow to execute cpack after a successful build.
  • Workflow Artifacts: We used the actions/upload-artifact action to save the packages generated by CPack, making them downloadable from the workflow summary page.
  • Multi-Platform Builds with Matrix: We refactored our workflow to use a strategy: matrix. This allowed us to build and package for Windows, macOS, and Linux in parallel without duplicating our workflow code.
  • Using Matrix Variables: We used the ${{ matrix.os }} context variable to give each platform's artifact a unique name.
Next Lesson
Lesson 60 of 61

Creating Installers with CPack and GitHub Actions

Learn to automate the creation of a professional Windows installer by integrating CPack, the WiX Toolset, and a multi-platform GitHub Actions workflow.

Have a question about this lesson?
Answers are generated by AI models and may not be accurate