Integrating Tools with add_custom_command()

Learn how to attach commands to specific points in a target's build lifecycle using add_custom_command(TARGET) for tasks like post-build file copying.

Greg Filak
Published

So far, our build process has been focused entirely on one thing: compiling C++ code. But a real-world development workflow involves much more. We often need to run external tools, copy files, or execute scripts as part of the build. Some common examples include:

  • Copying a final executable to a specific deployment directory.
  • Running a script to sign a binary after it has been built.
  • Generating a checksum for a release artifact.

This lesson introduces the add_custom_command() command with its TARGET signature. This is CMake's mechanism for attaching arbitrary actions to specific moments in a target's build lifecycle, allowing you to integrate any external tool or script into your build process.

Using the TARGET Signature of add_custom_command()

We've previously seen how the OUTPUT signature of add_custom_command() is used to generate source files. The TARGET signature serves a different purpose: it attaches a command to the lifecycle of an existing target. It doesn't create new files that other targets depend on; it simply runs a command at a specified time.

The basic syntax is:

add_custom_command(TARGET <name> <timing>
  COMMAND ...
  COMMENT "..."
)
  • The <name> is the name of an existing target, like our GreeterApp.
  • The <timing> keyword lets us specify exactly when our command should run within the build process of our target. We'll walk through the available timings in the next section.
  • The COMMAND keyword lets us specify the action we want to take
  • The COMMENT keyword lets us output an optional message to our build log when the command is executed

The Timing Hooks

There are three primary timings we can use:

  • PRE_BUILD: The command runs before any other build rule for the target is executed. This is useful for setup tasks, like generating files that the compiler will need. We walk through a practical example of this in the next lesson.
  • PRE_LINK: The command runs after all source files for the target have been compiled into object files, but before the final link step that creates the executable or library. This is a more niche option, useful for tasks that need to process object files.
  • POST_BUILD: This is the most common and useful timing. The command runs after the target has been successfully built. It's perfect for post-processing tasks like copying the final binary, running a code signing tool, or packaging assets.

Let's see these timings in action. Our command will use the basic echo program available on almost all platforms to log out a message. We'll run echo on each phase of our GreeterApp build in app/CMakeLists.txt:

Files

CMakeLists.txt
app
Select a file to view its content

Let's test our changes. Note that these commands will only be executed if GreeterApp needs to be built. If it already exists in our build directory and hasn't changed, it may not be recompiled, meaning our commands will not be executed.

To be sure our program gets built, we can run the build step from a clean build directory.

cmake --build .

The build log should show the order of operations, with our echo output documenting each step:

PRE_BUILD: GreeterApp is about to be built
PRE_LINK: Sources compiled, about to link
POST_BUILD: GreeterApp has been built successfully

Portable Commands

The COMMAND argument can run any executable program that is installed on our machine, and accessible from the command line.

However, quite often, the commands we need will be basic, built-in options that are included with most platforms. echo is an example of this, but we could also copy files using cp, for example.

However, there is a portability problem here. cp exists on Linux and macOS, but Windows' equivalent is called copy. Commands that exist on multiple platforms may also work slightly differently, perhaps requiring different arguments.

We already know a few ways to solve this - the most obvious being something like an if(WIN32) check, but CMake provides a more elegant solution. The cmake executable bundles a suite of commands that are commonly needed by build scripts, including portable implementations of things like echo and cp/copy.

Rewriting one of our echo examples to use the version of echo implemented by cmake would look like this:

add_custom_command(TARGET GreeterApp POST_BUILD
  COMMAND ${CMAKE_COMMAND} -E echo
  "POST_BUILD: GreeterApp has been built successfully."
)

${CMAKE_COMMAND} is a variable that holds the absolute path to the cmake executable itself.

The -E flag (for "execute") tells CMake not to start a build, but to instead run one of its own built-in, cross-platform utility commands.

You can get a full list of available commands by running cmake -E help, but some of the most useful are:

  • copy: Copies files.
  • make_directory: Creates a directory.
  • copy_directory: Copies entire directories.
  • copy_directory_if_different: Copies only the content that has changed from one directory to another
  • rename: Renames a file or directory.
  • remove: Deletes files.

By using ${CMAKE_COMMAND} -E <command> instead of a native shell command, we ensure our custom commands will work on any platform.

Practical Example: Post-Build File Copying

Let's implement a classic and highly useful scenario: automatically copying our final executable to some other directory after a successful build. This is a common requirement for gathering all the necessary artifacts for testing or packaging.

We'll add this logic to app/CMakeLists.txt. First, let's create a variable to store our destination, and use a file() command to ensure it exists:

app/CMakeLists.txt

cmake_minimum_required(VERSION 3.21)

add_executable(GreeterApp src/main.cpp)

# Define the destination directory and ensure it exists
set(DEPLOY_DIR "${PROJECT_SOURCE_DIR}/deploy") 
file(MAKE_DIRECTORY ${DEPLOY_DIR})

Next, we'll add our custom command. The cmake -E mode includes a copy command that we can use for this. Remember, we can get the output of a target using the <TARGET_FILE:...> generator expression:

app/CMakeLists.txt

cmake_minimum_required(VERSION 3.21)

add_executable(GreeterApp src/main.cpp)

set(DEPLOY_DIR "${PROJECT_SOURCE_DIR}/deploy")
file(MAKE_DIRECTORY ${DEPLOY_DIR})

add_custom_command(TARGET GreeterApp POST_BUILD 
  COMMENT "Copying executable to ${DEPLOY_DIR}" 
  COMMAND ${CMAKE_COMMAND} -E copy 
    $<TARGET_FILE:GreeterApp> 
    ${DEPLOY_DIR} 
)

Let's review the key components of this script:

  1. We create a variable DEPLOY_DIR to hold our destination path. This avoids hardcoding the path multiple times and makes the script easier to read and modify. We use file(MAKE_DIRECTORY) to ensure this directory exists before we try to copy into it.
  2. We use a POST_BUILD command because we want to act on the final, successfully built executable.
  3. We add a COMMENT to explain what this command is doing. This comment may also get output into the build log
  4. To copy a file, we use the portable ${CMAKE_COMMAND} -E copy.
  5. We use the $<TARGET_FILE:GreeterApp> generator expression. This is the only reliable way to get the path to the final executable. It correctly resolves to GreeterApp.exe on Windows and GreeterApp on Linux, and it works regardless of the build configuration or generator.
  6. We provide our DEPLOY_DIR as the destination directory we want our output copied to.

Now, the next time we build our executable, it will be copied to the /deploy directory in our project root.

cmake --build .

If you look in your project's root, you'll find a new deploy/ directory containing your GreeterApp executable. You may also see our COMMENT output to the build log:

Linking CXX executable GreeterApp
Copying executable to D:/Projects/cmake/deploy

Remember, running a build may not actually build our executable if it is already in our /build folder and our build system thinks it is up-to-date. If our commands aren't working, try deleting the existing executable (or the entire build directory) first.

On some environments, the COMMENT may not show by default unless we increase the output verbosity. If it is important that the message is visible, we can use the echo technique from before. We can pass multiple COMMAND arguments like this:

add_custom_command(TARGET GreeterApp POST_BUILD
  COMMAND ${CMAKE_COMMAND} -E echo 
    "Copying executable to ${DEPLOY_DIR}" 
  COMMAND ${CMAKE_COMMAND} -E copy
    $<TARGET_FILE:GreeterApp>
    ${DEPLOY_DIR}
)

Comparing install() vs. POST_BUILD Copy

For more complex deployment and packaging scenarios, using the install() workflow we covered in is generally recommended. It provides fine-grained control over the entire process. It also makes our program easier to package with CPack, which we'll cover later in the course.

However, for simple projects, or for simple tasks within a larger project, a POST_BUILD custom command is easier, more direct, and perfectly appropriate.

Understanding the Limitations

There's an important aspect of add_custom_command(TARGET ...) that often trips up beginners. If we run our build again, without changing anything, our commands may not be executed.

This is because a custom command attached to a target is considered part of that target's dependency graph. The build system is smart; it sees that GreeterApp is already up-to-date (its source files haven't changed), so it doesn't rebuild it. And if the target isn't rebuilt, its associated pre- and post-build commands are not run.

This is usually the desired behavior. You don't want to re-copy a file every single time you build if nothing has changed.

But what if you have a task that you always want to be able to run on demand, regardless of any changes? That's the job of a custom target, which we'll cover in the next lesson.

Summary

The add_custom_command(TARGET ...) command lets us integrate external scripts and tools into our build process, attaching them to the lifecycle of your existing targets.

  • Three Lifecycle Hooks: You can run commands at PRE_BUILD, PRE_LINK, or POST_BUILD stages. POST_BUILD is the most common.
  • Portability: The ${CMAKE_COMMAND} -E <command> approach provides portable implementations of common command line tasks, such as working with the file system.
  • Use Generator Expressions: Use $<TARGET_FILE:TargetName> to reliably get the path to a target's output file.
  • It Only Runs When the Target Rebuilds: A custom command attached to a target is part of that target's build. If the target is up-to-date, the command will not be executed.
Have a question about this lesson?
Answers are generated by AI models and may not be accurate