-
Notifications
You must be signed in to change notification settings - Fork 225
Add GENERATED package lock mode (pnpm/npm style, commit-pinned) #707
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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} | ||
|
|
@@ -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})" | ||
| ) | ||
|
|
@@ -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() | ||
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In case of nested CPM dependencies, the value of |
||
|
|
||
| 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}) | ||
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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() | ||
|
|
||
| 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") |
| 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") |
There was a problem hiding this comment.
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
OPTIONSwill 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.
CPMAddPackagewould still be respected, the lock should only impact theGIT_TAGandGIT_REPOSITORYentries.Implementing this in the future could be a breaking change or lead to unexpected updates, so it makes sense to advertise caution for now.