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.

Greg Filak
Published

In the last lesson, we configured our GitHub Actions workflow to build our project and package it into a ZIP archive. This archive is an "artifact" of the workflow, a downloadable file that proves that our build and packaging process works across multiple platforms.

A .zip file with source code and CMake configuration files is useful for sharing libraries with other developers, but it's not a user-friendly way to distribute an application. Non-technical users expect a guided installation experience. This lesson will extend our CI pipeline to produce just that. We'll create a Windows installer (.msi file) in this example, but we'll use our GitHub Actions workflow, so it's not necessary to have a Windows machine or to use cross-compilation.

We will use the CPack WIX generator, integrate the necessary tools into our CI environment, and use CMake Presets to manage the entire automated workflow.

Setting Up the Windows Environment

For reference, our basic GreeterApp is included below:

Files

app
tests
Select a file to view its content

To package this, we'll use the WiX Toolset, an open-source project for building Windows installation packages from XML source code. CPack automatically generates these WiX XML files for us and then calls the WiX compiler to build the final .msi package.

The CPack WIX generator is only a front-end for this toolset. It allows WiX to be configured and interacted with from CMake, but CMake doesn't include the tool itself. Conveniently, GitHub Action's Windows runner does come with WiX preinstalled but, if you want to run it locally or on other environments, you will need to install it.

WiX is available on most poplar Windows package managers, such as Chocolatey.

These package managers are also available on the Windows runner, which makes it easy to install tools we need that aren't included by default. For example, we could install Doxygen like this:

.github/workflows/ci.yml

name: C++ CI

on:
  pull_request:
    branches: ["main"]

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

      - name: Install Doxygen
        run: choco install doxygen.install

# ...

Packaging with a Windows-Specific Preset

Let's start with the simplest case: our GreeterApp is built by statically linking against GreeterLib. In this scenario, the GreeterApp.exe is a self-contained executable. All the code from GreeterLib has been copied into it, so there are no external .dll files to worry about.

Step 1: Define Components and Install Rules

First, we need to tell CMake what to install. We'll continue to use CPack components to group our files. For this simple case, we only need one component for our application's executable.

In app/CMakeLists.txt, we'll add an install() command for our GreeterApp target, assigning it to a component named Application.

app/CMakeLists.txt

cmake_minimum_required(VERSION 3.23)

add_library(GreeterAppLogic src/run.cpp)
find_package(spdlog CONFIG REQUIRED)

target_link_libraries(GreeterAppLogic PRIVATE
  GreeterLib
  spdlog::spdlog
)

target_sources(GreeterAppLogic PUBLIC
  FILE_SET HEADERS
  BASE_DIRS "include"
  FILES "include/app/run.h"
)

add_executable(GreeterApp src/main.cpp)
target_link_libraries(GreeterApp PRIVATE GreeterAppLogic)

include(GNUInstallDirs)
install(
  TARGETS GreeterApp
  RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
  COMPONENT Application
)

The DESTINATION bin tells the installer to place the executable in a bin subdirectory inside the final installation folder (e.g., C:\Program Files\Greeter\bin).

Next, in our root CMakeLists.txt, we need to inform CPack about this new component. We do this with the cpack_add_component() command, which allows us to provide a friendly name and description that will be shown to the user in the installer's UI.

CMakeLists.txt

cmake_minimum_required(VERSION 3.23)
project(Greeter VERSION 1.0)

include(cmake/Coverage.cmake)
include(cmake/Sanitize.cmake)
include(cmake/Tidy.cmake)

add_subdirectory(app)
add_subdirectory(greeter)

enable_testing()
add_subdirectory(tests)

add_subdirectory(benchmarks)

set(CPACK_ARCHIVE_COMPONENT_INSTALL ON)

include(CPack)

cpack_add_component(Development
  DISPLAY_NAME "The Greeter Library"
  DESCRIPTION "Compiled Library, Header Files, and CMake Configuration Files"
)

cpack_add_component(Application
  DISPLAY_NAME "The Greeter Application"
  DESCRIPTION "An application that says hello to the user"
)

Step 2: Create a Windows-Specific Preset

For now, we'll ensure our application is a single, self-contained .exe by using static linking. This means we need to set several CMake and vcpkg variables. The best way to manage this is with a dedicated configure preset.

In CMakePresets.json, we'll create a windows-static-release preset that inherits from our base release preset and sets the necessary variables for a fully static Windows build.

CMakePresets.json

{
  "version": 6,
  "configurePresets": [
     // ...
  {
    "name": "windows-static-release",
    "inherits": "release",
    "cacheVariables": {
      "BUILD_SHARED_LIBS": "OFF",
      "CMAKE_MSVC_RUNTIME_LIBRARY": "MultiThreaded",
      "CMAKE_EXE_LINKER_FLAGS": "/INCREMENTAL:NO",
      "VCPKG_TARGET_TRIPLET": "x64-windows-static"
    }
  }],
  // ...
}

Let's break down these cache variables:

  • Disabling BUILD_SHARED_LIBS tells our own libraries (like GreeterLib) to build as static. This is the default behaviour anyway, but we're just being explicit about our requirements here. We covered this variable .
  • Setting CMAKE_MSVC_RUNTIME_LIBRARY to MultiThreaded corresponds to the /MT compiler flag, which links against the static version of the Microsoft Visual C++ Runtime.
  • The /INCREMENTAL:NO compiler flag does TODO
  • Setting VCPKG_TARGET_TRIPLET to x64-windows-static tells vcpkg to find and install the static versions of our dependencies (like spdlog).

Step 3: Create the Installer Preset

Next, we'll create a new package preset in CMakePresets.json. This preset will use the WIX generator and specify that it should only include the Application component:

CMakePresets.json

{
  "version": 6,
  // ...
  "packagePresets": [
    // ...
    {
    "name": "windows-release-installer",
    "displayName": "Windows Installer (MSI)",
    "inherits": "default",
    "generators": ["WIX"],
    "variables": {
      "CPACK_COMPONENTS_ALL": "Application"
    }
  }]
}

Step 4: Updating the CI Workflow

With these changes, our CI workflow can now generate a basic installer. We need to add conditional logic so that the Windows-specific steps only run on the Windows runner:

.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 (Windows)
        if: matrix.os == 'windows-latest'
        run: cmake --preset windows-static-release
      - name: Configure CMake (Linux/macOS)
        if: matrix.os != 'windows-latest'
        run: cmake --preset release

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

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

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

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

      - name: Package Application
        if: matrix.os == 'windows-latest'
        run: cpack --preset windows-release-installer

      - name: Upload Application Package Artifact
        if: matrix.os == 'windows-latest'
        uses: actions/upload-artifact@v4
        with:
          name: Greeter-Installer-${{ matrix.os }}
          path: build/Greeter-*.msi

GitHub steps can be conditional. In this example, we're using if: matrix.os == 'windows-latest' to ensure the Windows-specific configure, package, and upload steps are only executed on the Windows runner.

After committing and pushing our changes to a PR targetting the main branch, we should see our workflow start. The Windows job will include our two extra steps and, when it is complete, we'll see the installer available in our artifact list:

If you have a Windows machine, downloading and running the package will open a familiar UI, which will install our executable after walking through all the steps:

We'll learn how to customize this installer later in the lesson

Packaging Runtime Dependencies

Let's cover a more complex scenario where our executable has runtime dependencies - .dll files on Windows. This means the installer must include not only GreeterApp.exe but also all the .dll files it depends on.

Depending on our preferred configuration, this might include external dependencies like spdlog.dll and fmt.dll, or internal dependencies such as our own GreeterLib being compiled as a .dll.

To test this, we'll switch back to using our release configuration preset on all platforms:

.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 Development
        run: cpack --preset dev-zip

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

      - name: Package Application
        if: matrix.os == 'windows-latest'
        run: cpack --preset windows-release-installer

      - name: Upload Application Package Artifact
        if: matrix.os == 'windows-latest'
        uses: actions/upload-artifact@v4
        with:
          name: Greeter-Installer-${{ matrix.os }}
          path: build/Greeter-*.msi

By default, vcpkg builds our dependencies as shared libraries (.dll files) so our executable will now have runtime dependencies. For this project, those are the spdlog and fmt libraries.

Installing Runtime Dependencies

The most direct way of installing our runtime dependencies is simple install(FILES ...) commands. We simply provide the path to our dependencies. For dependencies managed by vcpkg, there are some variables that can help us track them down:

install(FILES
  "${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/bin/libfmt.dll"
  "${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/bin/libspdlog.dll"
  DESTINATION ${CMAKE_INSTALL_BINDIR}
  COMPONENT Application
)

This would work, but only on Windows, and only for release configurations. We could do more work to make this portable, however, CMake 3.21 introduced the RUNTIME_DEPENDENCIES keyword to the install() command to make this problem slightly easier to solve.

Using RUNTIME_DEPENDENCIES

The following example would install all runtime dependencies of SomeTarget into the binary directory, and assign them to the Application component:

install(
  TARGETS SomeTarget
  RUNTIME_DEPENDENCIES
  DESTINATION ${CMAKE_INSTALL_BINDIR}
  COMPONENT Application
)

However, in most real-world scenarios, this command tries to install too many things. Our programs will typically rely on dependencies provided by the platform itself, such as DLL files that are included with Windows. We don't want those to be included in our package, so we need to filter this list.

Filtering RUNTIME_DEPENDENCIES

To filter the list of dependencies we want to install, the install() command provides a few options. The most useful tend to be PRE_EXCLUDE_REGEXES and PRE_INCLUDE_REGEXES. These allow us to provide regular expressions for files that we want to be excluded or included in our installation command.

In the following example, we exclude everything by default using the .* expression, and then include only dependencies that have fmt or spdlog in their name using .*fmt.* and .*spdlog.*:

app/CMakeLists.txt

cmake_minimum_required(VERSION 3.23)

add_library(GreeterAppLogic src/run.cpp)
find_package(spdlog CONFIG REQUIRED)

target_link_libraries(GreeterAppLogic PRIVATE
  GreeterLib
  spdlog::spdlog
)

target_sources(GreeterAppLogic PUBLIC
  FILE_SET HEADERS
  BASE_DIRS "include"
  FILES "include/app/run.h"
)

add_executable(GreeterApp src/main.cpp)
target_link_libraries(GreeterApp PRIVATE GreeterAppLogic)

include(GNUInstallDirs)
install(
  TARGETS GreeterApp
  DESTINATION ${CMAKE_INSTALL_BINDIR}
  COMPONENT Application
)

install(
  TARGETS GreeterApp
  RUNTIME_DEPENDENCIES
  PRE_INCLUDE_REGEXES ".*fmt.*" ".*spdlog.*"
  PRE_EXCLUDE_REGEXES ".*"
  DESTINATION ${CMAKE_INSTALL_BINDIR}
  COMPONENT Application
)

We would expand this list as we add more dependencies or, alternatively, change our approach where we include everything by default, and then specify only what we want to exclude.

Installing Internal Libraries

For internal runtime dependencies, such as our GreeterLib, we install those using regular install(TARGETS ...) commands:

install(
  TARGETS MyLibrary
  RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
    COMPONENT Application
)

Our GreeterLib's RUNTIME files are already being installed to the binary directory and assigned to the Development component. To include them with our installer, we just need to add them to the Application component and then build with BUILD_SHARED_LIBS enabled:

greeter/CMakeLists.txt

# ...

install(
  TARGETS GreeterLib
  EXPORT GreeterLibTargets
  ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
    COMPONENT Development
  LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
    COMPONENT Development
  RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
    COMPONENT Development
    COMPONENT Application 
  FILE_SET HEADERS DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
    COMPONENT Development
)

# ...

To build our target as a runtime dependency rather than a static library, we set the BUILD_SHARED_LIBS cache variable to "ON":

vcpkg.json

{
  "version": 6,
  "configurePresets": [
     // ...
  {
    "name": "shared-internal-libs",
    "inherits": "release",
    "cacheVariables": {
      "BUILD_SHARED_LIBS": "ON"
    }
  }],
  // ...
}

GUIDs

To create a valid MSI installer, our program needs a Globally Unique Identifier (GUID). This GUID is what Windows uses to track the installed product, manage updates, and handle uninstalls.

Every product must have a GUID but, if we don't provide one, WiX will automatically generate one at build time. However, this GUID will be different on every build. This makes it impossible for Windows to realise that the installers generated from two different builds are actually for the same application.

To address this, we should specify an exact GUID for our program and consistently use it. We can generate a GUID using command-line utilities (uuidgen on Linux/macOS, [guid]::NewGuid() in PowerShell) or an online tool like uuidgenerator.net.

Once you have a GUID, you set it in your root CMakeLists.txt using the CPACK_WIX_UPGRADE_GUID variable. This must be done before the include(CPack) command:

CMakeLists.txt

cmake_minimum_required(VERSION 3.23)
project(Greeter VERSION 1.0)

include(cmake/Coverage.cmake)
include(cmake/Sanitize.cmake)
include(cmake/Tidy.cmake)

add_subdirectory(app)
add_subdirectory(greeter)

enable_testing()
add_subdirectory(tests)
add_subdirectory(benchmarks)

set(CPACK_ARCHIVE_COMPONENT_INSTALL ON)
set(CPACK_WIX_UPGRADE_GUID "c8288165-c0b1-4c9a-ba61-877175be0b5a")
include(CPack)

cpack_add_component(Development
  DISPLAY_NAME "The Greeter Library"
  DESCRIPTION "Compiled Library, Header Files, and CMake Configuration Files"
)

cpack_add_component(Application
  DISPLAY_NAME "The Greeter Application"
  DESCRIPTION "An application that says hello to the user"
)

If we generate two different installers, they will now use this same GUID. If we install our program using one of the installers, and then run the second, Windows will understand that both are for the same underlying program.

Rather than treating the second installer as a separate program, it will now present options for the user to upgrade, repair, or uninstall what they already have:

Multi-Component Installers

Our installer now correctly bundles the executable and its required DLLs. However, installers can contain multiple components, and present users with options on which they want to install, uninstall, or upgrade.

Let's replicate this by imagining we have a simple Documentation.pdf file in our project root that explains how to use our program.

We set the install rules and configure the component in the usual way:

CMakeLists.txt

cmake_minimum_required(VERSION 3.23)
project(Greeter VERSION 1.0)

include(cmake/Coverage.cmake)
include(cmake/Sanitize.cmake)
include(cmake/Tidy.cmake)

add_subdirectory(app)
add_subdirectory(greeter)

enable_testing()
add_subdirectory(tests)
add_subdirectory(benchmarks)

set(CPACK_ARCHIVE_COMPONENT_INSTALL ON)
set(CPACK_WIX_UPGRADE_GUID "c8288165-c0b1-4c9a-ba61-877175be0b5a")
include(CPack)

cpack_add_component(Development
  DISPLAY_NAME "The Greeter Library"
  DESCRIPTION "Compiled Library, Header Files, and CMake Configuration Files"
)

cpack_add_component(Application
  DISPLAY_NAME "The Greeter Application"
  DESCRIPTION "An application that says hello to the user"
)

install(FILES
  ${CMAKE_SOURCE_DIR}/Documentation.pdf
  DESTINATION "docs"
  COMPONENT Documentation
)

cpack_add_component(Documentation
  DISPLAY_NAME "Greeter Documentation"
  DESCRIPTION "A PDF file explaining how to use the application"
)

Finally, we'll update our package preset to include both components:

CMakePresets.json

{
  "version": 6,
  // ...
  
  "packagePresets": [
    // ...
    {
      "name": "windows-release-installer",
      "displayName": "Windows Installer (MSI)",
      "inherits": "default",
      "generators": ["WIX"],
      "variables": {
        "CPACK_COMPONENTS_ALL": "Application" 
        "CPACK_COMPONENTS_ALL": "Application;Documentation" 
      }
    }
  ]
}

Now, when a user runs the installer, they will see a feature tree where they can inspect the different parts of the installation:

This technique is particularly useful when used with components that are downloaded on-demand. This means users don't download a massive installer containing components they may not want. Instead, we ship a lightweight installer which downloads components on demand based on what users select within the UI. We won't cover this here, as it requires fairly advanced setup, as well as setting up a web server to host our downloads.

When using WiX, this can be implemented using Burn bundles, and the CPack module we're using provides a friendlier front end based around the cpack_configure_downloads() command documented here.

Component Customization

The cpack_add_component() command offers further options to control how our components are presented, and how they interact with each other.

We can state that one component depends on another component using the DEPENDS keyword:

cpack_add_component(Application
  DISPLAY_NAME "The Greeter Application"
  DESCRIPTION "An application that says hello to the user"
  DEPENDS RuntimeDependencies
)

cpack_add_component(RuntimeDependencies
  DISPLAY_NAME "The Runtime Dependencies"
  DESCRIPTION "DLLs and other files that are required for the application"
)

By default, the installer will assume the user wants to install all of our components. The easiest way to change that is by setting components to DISABLED, meaning they will initially be deselected:

cpack_add_component(OptionalStuff
  DISPLAY_NAME "Optional Stuff"
  DESCRIPTION "You probably dont want this"
  DISABLED 
)

We can set a component as REQUIRED, meaning it must be installed and cannot be deselected:

cpack_add_component(Application
  DISPLAY_NAME "The Greeter Application"
  DESCRIPTION "An application that says hello to the user"
  REQUIRED 
  DEPENDS RuntimeDependencies
)

Finally, we can set a component as HIDDEN. This effectively removes the component from the visible options, but still includes it in the installation (assuming it's not also DISABLED):

# Everyone needs this and they don't need to know it exists
# So we just mark it as REQUIRED and HIDDEN
cpack_add_component(RuntimeDependencies
  DISPLAY_NAME "The Runtime Dependencies"
  DESCRIPTION "DLLs and other files that are required for the application"
  REQUIRED HIDDEN 
)

Component Groups

To further improve the user experience, we can group related components together. Let's create a SupportingFiles group for our docs, and other ancillary components we might need in the future.

We do this using the cpack_add_component_group() command. Groups can also be nested inside other groups by setting the PARENT_GROUP within this command.

We assign components to a group using the GROUP argument of their cpack_add_component() command or, alternatively, setting CPACK_COMPONENT_<Name>_GROUP:

CMakeLists.txt

cmake_minimum_required(VERSION 3.23)
project(Greeter VERSION 1.0)

include(cmake/Coverage.cmake)
include(cmake/Sanitize.cmake)
include(cmake/Tidy.cmake)

add_subdirectory(app)
add_subdirectory(greeter)

enable_testing()
add_subdirectory(tests)
add_subdirectory(benchmarks)

set(CPACK_ARCHIVE_COMPONENT_INSTALL ON)
set(CPACK_WIX_UPGRADE_GUID "c8288165-c0b1-4c9a-ba61-877175be0b5a")
include(CPack)

cpack_add_component(Development
  DISPLAY_NAME "The Greeter Library"
  DESCRIPTION "Compiled Library, Header Files, and CMake Configuration Files"
)

cpack_add_component(Application
  DISPLAY_NAME "The Greeter Application"
  DESCRIPTION "An application that says hello to the user"
)

install(FILES
  ${CMAKE_SOURCE_DIR}/Documentation.pdf
  DESTINATION "docs"
  COMPONENT Documentation
)

cpack_add_component_group(SupportingFiles 
  DISPLAY_NAME "Supporting Files" 
  DESCRIPTION "Additional optional content" 
  PARENT_GROUP "" # Top-level group 
  EXPANDED # Expanded by default in the installer UI 
)

cpack_add_component(Documentation
  DISPLAY_NAME "Greeter Documentation"
  DESCRIPTION "A PDF file explaining how to use the application"
  GROUP SupportingFiles 
)

# Alternatively:
set(CPACK_COMPONENT_Documentation_GROUP SupportingFiles)

This change results in a more organized feature selection screen in the installer, which is especially helpful for large applications with many optional components.

Customizing the Installer

Most of what we've configured so far, such as our cpack_add_component() and cpack_add_component_group() comments, have been generic settings that are compatible across a wide range of package types. If we were to switch our generator to NSIS, an alternative to WIX, our component configuration would work there, too.

However, CPack includes further, more specific settings for each individual generator. These generators, and how we can use them, are listed on the official docs.

The .zip packages we were using earlier were created using the Archive generator, and the installer we created in this lesson is using the WiX generator.

Behind the scenes, WiX installers are hugely customizable. The WiX documentation lists all the possibilities. CPack's WiX generator offers a front-end to this, allowing us to customize things by setting CMake variables.

For example, let's modify the license that is shown by default in the installation steps. This licence can be styled and exported in the rich text format (.rtf) supported by most document editors:

license.rtf

{\rtf1\ansi\deff0
{\fonttbl{\f0 Arial;}}
{\colortbl;\red0\green0\blue0;}
\pard
\f0\fs28\b Sample License\par
\fs20 This is a sample license file.\par
\i You agree to all terms and conditions by installing this software.\i0\par
}

To have WiX use this file, we set the CPACK_WIX_LICENSE_RTF variable:

CMakeLists.txt

# ...
set(CPACK_ARCHIVE_COMPONENT_INSTALL ON)
set(CPACK_WIX_UPGRADE_GUID "c8288165-c0b1-4c9a-ba61-877175be0b5a")
set(CPACK_WIX_LICENSE_RTF "${CMAKE_SOURCE_DIR}/license.rtf")
include(CPack)
# ...

There are many more variables we can set, controlling things like the images shown in the installer (CPACK_WIX_UI_BANNER and CPACK_WIX_UI_DIALOG) as well as the icon we want to use for our program (CPACK_WIX_PRODUCT_ICON).

The generator's official docs list all the possibilities.

Summary

In this lesson, we've gone beyond simple archives to create user-friendly installer, and we've automated its creation in our CI pipeline.

  • The WiX Toolset: CPack's WIX generator is a front-end for the WiX Toolset, which builds .msi installers.
  • Handling Dependencies: We saw how to create a self-contained executable with static linking and, more importantly, how to use install(RUNTIME_DEPENDENCIES) to automatically bundle all required .dll files for a shared library build.
  • CI Automation: We used a matrix strategy in GitHub Actions to build our installer exclusively on the Windows runner, demonstrating how to handle platform-specific packaging in a multi-platform workflow.
  • Components and Groups: We used CPack components to group installed files, allowing us to create different packages from the same project and to provide a structured feature selection UI for the user.
  • Customizing Installers: We introduced how to customize an installer through CPack's generators, which act as a front-end to the underlying tool.
Next Lesson
Lesson 61 of 61

Deployment with GitHub Releases

Learn to automate the release process using a GitHub Actions workflow that builds, packages, and publishes artifacts to a GitHub Release

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