Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,38 @@ cmake -Bbuild
cmake --build build --target cpm-update-package-lock
```

### Generated package lock (pnpm/npm style)

> **Experimental.** This mode currently only locks the `GIT_TAG` and `GIT_REPOSITORY` of git
> packages. Any other arguments to `CPMAddPackage` (such as `OPTIONS`, `VERSION`, or an explicitly
> set commit) are still resolved from your `CMakeLists.txt`, not from the lock, and changing them
> will **not** take effect until you remove the lock file (or the affected entry) and reconfigure.
> A proper npm-style lock may cover these in the future, which could be a breaking change, so use
> with caution for now.

Pass `GENERATED` to keep the lock in sync automatically, the way `pnpm-lock.yaml` or
`package-lock.json` work:

```cmake
CPMUsePackageLock(package-lock.cmake GENERATED)
```

In this mode the lock at the given path is rewritten in your source tree on **every configure**,
and git packages are pinned to the **exact commit that was checked out** rather than the (possibly
moving) `GIT_TAG`. This makes the lock reproducible even when dependencies are declared against a
branch like `main`. No separate `cpm-update-package-lock` step is needed.

To refresh the pins (e.g. to pull in newer upstream commits), delete the lock file and reconfigure,
just like deleting a `pnpm-lock.yaml`:

```bash
rm package-lock.cmake
cmake -Bbuild
```

The same behaviour can be enabled globally without editing `CMakeLists.txt` by setting the
`CPM_GENERATE_PACKAGE_LOCK` option (or environment variable).
Comment on lines +280 to +310

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding documentation! We should add that also changing any additional arguments like the OPTIONS will not work without removing the lockfile or the entry from the lockfile first.

Maybe it would also make sense to mark this as experimental for now, as for a proper npm-style lock one might expect.

  • Changing the version or explicitly setting a commit hash in the source would also update the locked hash accordingly
  • Any other flags from CPMAddPackage would still be respected, the lock should only impact the GIT_TAG and GIT_REPOSITORY entries.

Implementing this in the future could be a breaking change or lead to unexpected updates, so it makes sense to advertise caution for now.


See the [wiki](https://github.com/cpm-cmake/CPM.cmake/wiki/Package-lock) for more info.

## Private repositories and CI
Expand Down
135 changes: 118 additions & 17 deletions cmake/CPM.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ option(CPM_INCLUDE_ALL_IN_PACKAGE_LOCK
"Add all packages added through CPM.cmake to the package lock"
$ENV{CPM_INCLUDE_ALL_IN_PACKAGE_LOCK}
)
option(
CPM_GENERATE_PACKAGE_LOCK
"Keep the package lock in sync on every configure (pnpm/npm style), pinning git deps to the resolved commit"
$ENV{CPM_GENERATE_PACKAGE_LOCK}
)
option(CPM_USE_NAMED_CACHE_DIRECTORIES
"Use additional directory of package name in cache on the most nested level."
$ENV{CPM_USE_NAMED_CACHE_DIRECTORIES}
Expand Down Expand Up @@ -945,16 +950,6 @@ function(CPMAddPackage)
cpm_create_module_file(${CPM_ARGS_NAME} "CPMAddPackage(\"${ARGN}\")")
endif()

if(CPM_PACKAGE_LOCK_ENABLED)
if((CPM_ARGS_VERSION AND NOT CPM_ARGS_SOURCE_DIR) OR CPM_INCLUDE_ALL_IN_PACKAGE_LOCK)
cpm_add_to_package_lock(${CPM_ARGS_NAME} "${ARGN}")
elseif(CPM_ARGS_SOURCE_DIR)
cpm_add_comment_to_package_lock(${CPM_ARGS_NAME} "local directory")
else()
cpm_add_comment_to_package_lock(${CPM_ARGS_NAME} "${ARGN}")
endif()
endif()

cpm_message(
STATUS "${CPM_INDENT} Adding package ${CPM_ARGS_NAME}@${CPM_ARGS_VERSION} (${PACKAGE_INFO})"
)
Expand Down Expand Up @@ -1006,6 +1001,41 @@ function(CPMAddPackage)
cpm_get_fetch_properties("${CPM_ARGS_NAME}")
endif()

# Record the package in the lock. This runs after the fetch so a generated lock can pin git
# packages to the commit that was actually checked out (the source dir now exists).
if(CPM_PACKAGE_LOCK_ENABLED)
set(CPM_LOCK_ARGN "${ARGN}")
if(CPM_PACKAGE_LOCK_GENERATED
AND DEFINED CPM_ARGS_GIT_REPOSITORY
AND DEFINED ${CPM_ARGS_NAME}_SOURCE_DIR
)
cpm_get_git_commit_hash("${${CPM_ARGS_NAME}_SOURCE_DIR}" CPM_LOCK_COMMIT)
if(CPM_LOCK_COMMIT)
# Replace the (possibly moving) GIT_TAG with the resolved commit, so the lock is
# reproducible regardless of where the branch/tag later points.
list(FIND CPM_LOCK_ARGN "GIT_TAG" CPM_LOCK_GIT_TAG_INDEX)
if(CPM_LOCK_GIT_TAG_INDEX GREATER -1)
math(EXPR CPM_LOCK_GIT_TAG_VALUE_INDEX "${CPM_LOCK_GIT_TAG_INDEX} + 1")
list(REMOVE_AT CPM_LOCK_ARGN ${CPM_LOCK_GIT_TAG_VALUE_INDEX})
list(INSERT CPM_LOCK_ARGN ${CPM_LOCK_GIT_TAG_VALUE_INDEX} "${CPM_LOCK_COMMIT}")
else()
list(APPEND CPM_LOCK_ARGN GIT_TAG "${CPM_LOCK_COMMIT}")
endif()
endif()
endif()

if((CPM_ARGS_VERSION AND NOT CPM_ARGS_SOURCE_DIR)
OR CPM_INCLUDE_ALL_IN_PACKAGE_LOCK
OR (CPM_PACKAGE_LOCK_GENERATED AND NOT CPM_ARGS_SOURCE_DIR)
)
cpm_add_to_package_lock(${CPM_ARGS_NAME} "${CPM_LOCK_ARGN}")
elseif(CPM_ARGS_SOURCE_DIR)
cpm_add_comment_to_package_lock(${CPM_ARGS_NAME} "local directory")
else()
cpm_add_comment_to_package_lock(${CPM_ARGS_NAME} "${ARGN}")
endif()
endif()

set(${CPM_ARGS_NAME}_ADDED YES)
cpm_export_variables("${CPM_ARGS_NAME}")
endfunction()
Expand Down Expand Up @@ -1047,6 +1077,35 @@ macro(CPMDeclarePackage Name)
endif()
endmacro()

# Resolves the exact commit currently checked out in a git package's source dir, so a generated
# package lock can pin it. Sets ${result} to the empty string when it cannot be determined.
function(cpm_get_git_commit_hash repoPath result)
set(${result}
""
PARENT_SCOPE
)

find_package(Git QUIET)
if(NOT GIT_EXECUTABLE OR NOT EXISTS "${repoPath}/.git")
return()
endif()
Comment on lines +1088 to +1091

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case of nested CPM dependencies, the value of ${result} may still be set in the parent scope leading to an incorrect resolution, so we should unset ${result} in the parent scope here.


execute_process(
COMMAND ${GIT_EXECUTABLE} rev-parse HEAD
WORKING_DIRECTORY ${repoPath}
RESULT_VARIABLE resultGitRevParse
OUTPUT_VARIABLE commitHash
OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET
)

if(resultGitRevParse EQUAL 0)
set(${result}
"${commitHash}"
PARENT_SCOPE
)
endif()
endfunction()

function(cpm_add_to_package_lock Name)
if(NOT CPM_DONT_CREATE_PACKAGE_LOCK)
cpm_prettify_package_arguments(PRETTY_ARGN false ${ARGN})
Expand All @@ -1063,19 +1122,61 @@ function(cpm_add_comment_to_package_lock Name)
endif()
endfunction()

# includes the package lock file if it exists and creates a target `cpm-update-package-lock` to
# update it
# Includes the package lock file if it exists. In the default mode this also creates a
# `cpm-update-package-lock` target that copies the lock generated in the binary dir back to <file>.
#
# Pass GENERATED (or set CPM_GENERATE_PACKAGE_LOCK) for pnpm/npm-style behaviour instead: the lock
# at <file> is (re)written in the source tree on every configure and git packages are pinned to the
# exact commit that was checked out, so it stays in sync with no separate update step. The
# `cpm-update-package-lock` target is not created in this mode. Delete the file and reconfigure to
# refresh the pins, just like deleting a `pnpm-lock.yaml`.
macro(CPMUsePackageLock file)
if(NOT CPM_DONT_CREATE_PACKAGE_LOCK)
# A nested CPM dependency may also call CPMUsePackageLock during configuration. Only the first
# (top-level) call should take effect, otherwise a sub-project would overwrite the consuming
# project's lock file. A GLOBAL property is used as the guard so it is visible across all
# directory scopes yet resets on every fresh configure run (unlike a cache entry, which would
# persist and wrongly skip the top-level call on the next configure).
get_property(
CPM_PACKAGE_LOCK_INITIALIZED GLOBAL
PROPERTY CPM_PACKAGE_LOCK_INITIALIZED
SET
)
if(NOT CPM_DONT_CREATE_PACKAGE_LOCK AND NOT CPM_PACKAGE_LOCK_INITIALIZED)
set_property(GLOBAL PROPERTY CPM_PACKAGE_LOCK_INITIALIZED true)
cmake_parse_arguments(CPM_USE_LOCK "GENERATED" "" "" ${ARGN})

get_filename_component(CPM_ABSOLUTE_PACKAGE_LOCK_PATH ${file} ABSOLUTE)

# Apply any existing pins before the lock is (re)written below.
if(EXISTS ${CPM_ABSOLUTE_PACKAGE_LOCK_PATH})
include(${CPM_ABSOLUTE_PACKAGE_LOCK_PATH})
endif()
if(NOT TARGET cpm-update-package-lock)
add_custom_target(
cpm-update-package-lock COMMAND ${CMAKE_COMMAND} -E copy ${CPM_PACKAGE_LOCK_FILE}
${CPM_ABSOLUTE_PACKAGE_LOCK_PATH}

if(CPM_USE_LOCK_GENERATED OR CPM_GENERATE_PACKAGE_LOCK)
# Author the lock directly in the source tree and keep it current on every configure.
set(CPM_PACKAGE_LOCK_GENERATED
TRUE
CACHE INTERNAL ""
)
set(CPM_PACKAGE_LOCK_FILE
"${CPM_ABSOLUTE_PACKAGE_LOCK_PATH}"
CACHE INTERNAL ""
)
file(
WRITE ${CPM_PACKAGE_LOCK_FILE}
"# CPM Package Lock\n# This file is generated by CPM.cmake and should be committed to version control\n\n"
)
Comment on lines +1156 to +1168

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit unclear to me what will happen if a dependency also uses CPM with a package lock file, I think this might overwrite the existing package lock again. I think calling CPMUsePackageLock should be a no-op if it has already been called at any point before.

else()
set(CPM_PACKAGE_LOCK_GENERATED
FALSE
CACHE INTERNAL ""
)
if(NOT TARGET cpm-update-package-lock)
add_custom_target(
cpm-update-package-lock COMMAND ${CMAKE_COMMAND} -E copy ${CPM_PACKAGE_LOCK_FILE}
${CPM_ABSOLUTE_PACKAGE_LOCK_PATH}
)
endif()
endif()
set(CPM_PACKAGE_LOCK_ENABLED true)
endif()
Expand Down
90 changes: 90 additions & 0 deletions test/unit/package-lock-generated.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
include(${CPM_PATH}/testing.cmake)

# Exercises `CPMUsePackageLock(<file> GENERATED)`: the lock must be written into the source tree on
# configure (no cpm-update-package-lock step) with git packages pinned to the resolved commit. Uses
# a throwaway local git repo so the test is hermetic (no network).

find_package(Git REQUIRED)

set(SCRATCH ${CMAKE_CURRENT_BINARY_DIR}/package-lock-generated)
set(REPO ${SCRATCH}/dep-repo)
set(PROJECT_DIR ${SCRATCH}/project)
set(BUILD_DIR ${SCRATCH}/build)
set(LOCK ${PROJECT_DIR}/package-lock.cmake)

execute_process(COMMAND ${CMAKE_COMMAND} -E rm -rf ${SCRATCH})
file(MAKE_DIRECTORY ${REPO})

# ---- create a dependency git repo with a single commit on branch `testbranch` ----
file(WRITE ${REPO}/VERSION "1.0")

function(git)
execute_process(
COMMAND ${GIT_EXECUTABLE} ${ARGN}
WORKING_DIRECTORY ${REPO}
RESULT_VARIABLE git_result
OUTPUT_QUIET ERROR_QUIET
)
assert_equal(${git_result} "0")
endfunction()

git(init)
git(config user.email "test@example.com")
git(config user.name "CPM Test")
git(add -A)
git(commit -m "initial commit")
git(branch -M testbranch)

execute_process(
COMMAND ${GIT_EXECUTABLE} rev-parse HEAD
WORKING_DIRECTORY ${REPO}
OUTPUT_VARIABLE EXPECTED_SHA
OUTPUT_STRIP_TRAILING_WHITESPACE
)

# ---- a project that locks the dependency in GENERATED mode, declared against the branch ----
file(MAKE_DIRECTORY ${PROJECT_DIR})
file(
WRITE ${PROJECT_DIR}/CMakeLists.txt
"cmake_minimum_required(VERSION 3.14)
project(GeneratedLockTest NONE)
include(${CPM_PATH}/CPM.cmake)
CPMUsePackageLock(package-lock.cmake GENERATED)
CPMAddPackage(
NAME Dep
GIT_REPOSITORY ${REPO}
GIT_TAG testbranch
DOWNLOAD_ONLY YES
)
"
)

# Configure only — no `cpm-update-package-lock` target is built.
execute_process(COMMAND ${CMAKE_COMMAND} -S ${PROJECT_DIR} -B ${BUILD_DIR} RESULT_VARIABLE ret)
assert_equal(${ret} "0")

# The lock must have been authored into the source tree automatically.
assert_exists(${LOCK})

file(READ ${LOCK} LOCK_RUN1)

string(FIND "${LOCK_RUN1}" "${EXPECTED_SHA}" sha_pos)
if(sha_pos EQUAL -1)
assertion_failed(
"generated lock did not pin Dep to the resolved commit ${EXPECTED_SHA}:\n${LOCK_RUN1}"
)
endif()
message(STATUS "test passed: generated lock pinned Dep to ${EXPECTED_SHA}")

string(FIND "${LOCK_RUN1}" "GIT_TAG testbranch" branch_pos)
if(NOT branch_pos EQUAL -1)
assertion_failed("generated lock still references the moving branch instead of a commit")
endif()
message(STATUS "test passed: generated lock does not reference the moving branch")

# Reconfiguring must be idempotent: the pin is consumed and an identical lock is produced.
execute_process(COMMAND ${CMAKE_COMMAND} -S ${PROJECT_DIR} -B ${BUILD_DIR} RESULT_VARIABLE ret2)
assert_equal(${ret2} "0")
file(READ ${LOCK} LOCK_RUN2)
assert_equal("${LOCK_RUN1}" "${LOCK_RUN2}")
message(STATUS "test passed: generated lock is stable across reconfigures")
88 changes: 88 additions & 0 deletions test/unit/package-lock-nested.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
include(${CPM_PATH}/testing.cmake)

# Regression test for nested CPMUsePackageLock calls: when a dependency also calls
# CPMUsePackageLock, the nested call must be a no-op so it does not overwrite (or redirect writes
# away from) the consuming project's lock file. Uses a throwaway local git repo so the test is
# hermetic (no network).

find_package(Git REQUIRED)

set(SCRATCH ${CMAKE_CURRENT_BINARY_DIR}/package-lock-nested)
set(REPO ${SCRATCH}/dep-repo)
set(PROJECT_DIR ${SCRATCH}/project)
set(BUILD_DIR ${SCRATCH}/build)
set(LOCK ${PROJECT_DIR}/package-lock.cmake)

execute_process(COMMAND ${CMAKE_COMMAND} -E rm -rf ${SCRATCH})
file(MAKE_DIRECTORY ${REPO})

# ---- create a dependency that itself calls CPMUsePackageLock when added ----
file(
WRITE ${REPO}/CMakeLists.txt
"cmake_minimum_required(VERSION 3.14)
project(Dep NONE)
include(${CPM_PATH}/CPM.cmake)
# A nested lock declaration that must not take effect (and so must never create dep-lock.cmake).
CPMUsePackageLock(dep-lock.cmake GENERATED)
"
)

function(git)
execute_process(
COMMAND ${GIT_EXECUTABLE} ${ARGN}
WORKING_DIRECTORY ${REPO}
RESULT_VARIABLE git_result
OUTPUT_QUIET ERROR_QUIET
)
assert_equal(${git_result} "0")
endfunction()

git(init)
git(config user.email "test@example.com")
git(config user.name "CPM Test")
git(add -A)
git(commit -m "initial commit")
git(branch -M testbranch)

execute_process(
COMMAND ${GIT_EXECUTABLE} rev-parse HEAD
WORKING_DIRECTORY ${REPO}
OUTPUT_VARIABLE EXPECTED_SHA
OUTPUT_STRIP_TRAILING_WHITESPACE
)

# ---- a project that locks the dependency in GENERATED mode and pulls in the dependency ----
file(MAKE_DIRECTORY ${PROJECT_DIR})
file(
WRITE ${PROJECT_DIR}/CMakeLists.txt
"cmake_minimum_required(VERSION 3.14)
project(NestedLockTest NONE)
include(${CPM_PATH}/CPM.cmake)
CPMUsePackageLock(package-lock.cmake GENERATED)
CPMAddPackage(
NAME Dep
GIT_REPOSITORY ${REPO}
GIT_TAG testbranch
)
"
)

execute_process(COMMAND ${CMAKE_COMMAND} -S ${PROJECT_DIR} -B ${BUILD_DIR} RESULT_VARIABLE ret)
assert_equal(${ret} "0")

# The consuming project's lock must exist and pin the dependency: the nested CPMUsePackageLock in the
# dependency must not have redirected the recording to its own lock file.
assert_exists(${LOCK})
file(READ ${LOCK} LOCK_CONTENTS)
string(FIND "${LOCK_CONTENTS}" "${EXPECTED_SHA}" sha_pos)
if(sha_pos EQUAL -1)
assertion_failed(
"consuming project's lock did not pin Dep to the resolved commit ${EXPECTED_SHA}; the nested "
"CPMUsePackageLock call was not a no-op:\n${LOCK_CONTENTS}"
)
endif()
message(STATUS "test passed: nested CPMUsePackageLock did not hijack the consuming lock")

# The nested GENERATED call must not have authored a lock in the dependency's source tree.
assert_not_exists(${BUILD_DIR}/_deps/dep-src/dep-lock.cmake)
message(STATUS "test passed: nested CPMUsePackageLock did not create its own lock file")
Loading